feat(broadcast): finish multi-role insight experience

This commit is contained in:
2026-07-25 15:18:58 +08:00
parent 7990db6883
commit 230dfb4dfc
20 changed files with 552 additions and 81 deletions
@@ -30,6 +30,7 @@ import java.sql.Statement;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -160,13 +161,13 @@ public class AihrBroadcastAttachmentService {
""");
jdbcTemplate.update("""
update aihr_broadcast_attachment
set insight_status = 'PENDING', update_time = now()
set insight_status = 'PENDING', insight_version = null, update_time = now()
where status = 'READY' and insight_status = 'PROCESSING'
and update_time < date_sub(now(), interval 2 hour)
""");
trigger();
} catch (RuntimeException error) {
log.warn("broadcast attachment recovery failed");
log.warn("broadcast attachment recovery failed type={}", error.getClass().getSimpleName());
}
}
@@ -178,7 +179,7 @@ public class AihrBroadcastAttachmentService {
.whenComplete((ignored, error) -> {
workerRunning.set(false);
if (error != null) {
log.warn("broadcast attachment worker failed");
log.warn("broadcast attachment worker failed type={}", error.getClass().getSimpleName());
}
if (hasQueued()) {
trigger();
@@ -234,16 +235,18 @@ public class AihrBroadcastAttachmentService {
limit 10
""", (rs, rowNum) -> new InsightWork(
rs.getLong("id"), rs.getString("tenant_id"), rs.getString("file_name"),
rs.getString("extracted_text")
rs.getString("extracted_text"), null
));
for (InsightWork row : rows) {
String claimToken = "claim-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
int claimed = jdbcTemplate.update("""
update aihr_broadcast_attachment
set insight_status = 'PROCESSING', update_time = now()
set insight_status = 'PROCESSING', insight_version = ?, update_time = now()
where tenant_id = ? and id = ? and status = 'READY' and insight_status = 'PENDING'
""", row.tenantId(), row.id());
""", claimToken, row.tenantId(), row.id());
if (claimed == 1) {
return row;
return new InsightWork(
row.id(), row.tenantId(), row.fileName(), row.extractedText(), claimToken);
}
}
return null;
@@ -294,9 +297,10 @@ public class AihrBroadcastAttachmentService {
int updated = jdbcTemplate.update("""
update aihr_broadcast_attachment
set insight_status = ?, insights_json = ?, insight_version = ?, update_time = now()
where tenant_id = ? and id = ? and status = 'READY' and insight_status = 'PROCESSING'
where tenant_id = ? and id = ? and status = 'READY'
and insight_status = 'PROCESSING' and insight_version = ?
""", generation.status(), generation.json(), AihrBroadcastInsightService.VERSION,
work.tenantId(), work.id());
work.tenantId(), work.id(), work.claimToken());
if (updated != 1) {
log.info("broadcast attachment {} insights completed after its state changed", work.id());
}
@@ -306,8 +310,9 @@ public class AihrBroadcastAttachmentService {
update aihr_broadcast_attachment
set insight_status = 'FAILED', insights_json = null, insight_version = ?,
update_time = now()
where tenant_id = ? and id = ? and status = 'READY' and insight_status = 'PROCESSING'
""", AihrBroadcastInsightService.VERSION, work.tenantId(), work.id());
where tenant_id = ? and id = ? and status = 'READY'
and insight_status = 'PROCESSING' and insight_version = ?
""", AihrBroadcastInsightService.VERSION, work.tenantId(), work.id(), work.claimToken());
}
}
@@ -404,7 +409,8 @@ public class AihrBroadcastAttachmentService {
long id,
String tenantId,
String fileName,
String extractedText
String extractedText,
String claimToken
) {
}
}
@@ -3,6 +3,7 @@ package org.dromara.aihr.broadcast;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastEvidence;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastPerspective;
import org.dromara.aihr.domain.AihrModelDto.ChatRequest;
@@ -23,6 +24,7 @@ import static java.util.Map.entry;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrBroadcastInsightService {
public static final String VERSION = "role-perspective-v1";
@@ -49,7 +51,8 @@ public class AihrBroadcastInsightService {
private final AihrModelSeedService modelService;
public Generation generate(String fileName, String content) {
String source = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n').trim();
String normalized = content == null ? "" : content.replace("\r\n", "\n").replace('\r', '\n').trim();
String source = AihrSensitiveText.forModel(normalized);
if (source.isEmpty()) {
return new Generation("FAILED", null, List.of());
}
@@ -61,12 +64,12 @@ public class AihrBroadcastInsightService {
for (SourceChunk chunk : chunks) {
try {
ChatResponse response = modelService.chat(new ChatRequest(
AihrSensitiveText.forModel(prompt(fileName, chunk)),
prompt(fileName, chunk),
null,
AihrSensitiveText.forModel("""
你负责解读物业公司内部文件。只返回 JSON,不要 markdown。
只允许使用给定原文;原文未提及的岗位、结论、行动或风险不要输出。
evidence.quote 必须逐字复制原文,不能改写或拼接。
每个结论的 evidenceQuote 必须逐字复制原文,不能改写或拼接。
""")
));
if (response == null || !"openai-compatible".equals(response.mode())
@@ -76,8 +79,9 @@ public class AihrBroadcastInsightService {
for (BroadcastPerspective perspective : parseModelPerspectives(response.answer(), chunk, source)) {
merged.computeIfAbsent(perspective.code(), PerspectiveAccumulator::new).add(perspective);
}
} catch (RuntimeException ignored) {
} catch (RuntimeException error) {
failedChunks++;
log.warn("broadcast insight chunk failed type={}", error.getClass().getSimpleName());
}
}
List<BroadcastPerspective> perspectives = merged.values().stream()
@@ -93,7 +97,8 @@ public class AihrBroadcastInsightService {
return new Generation(status,
objectMapper.writeValueAsString(new StoredInsights(VERSION, perspectives)),
perspectives);
} catch (Exception ignored) {
} catch (Exception error) {
log.warn("broadcast insight serialization failed type={}", error.getClass().getSimpleName());
return new Generation("FAILED", null, List.of());
}
}
@@ -118,7 +123,8 @@ public class AihrBroadcastInsightService {
}
}
return List.copyOf(perspectives);
} catch (Exception ignored) {
} catch (Exception error) {
log.warn("broadcast stored insights invalid type={}", error.getClass().getSimpleName());
return List.of();
}
}
@@ -193,37 +199,77 @@ public class AihrBroadcastInsightService {
return null;
}
List<BroadcastEvidence> evidence = new ArrayList<>();
JsonNode evidenceRows = row.path("evidence");
if (evidenceRows.isArray()) {
for (JsonNode item : evidenceRows) {
String quote = limited(item.path("quote").asText(), 200);
if (quote.length() < 20 || !chunk.text().contains(quote) || !source.contains(quote)) {
continue;
}
int paragraph = Math.max(chunk.paragraphIndex(), item.path("paragraphIndex").asInt(chunk.paragraphIndex()));
if (evidence.stream().noneMatch(existing -> existing.quote().equals(quote))) {
evidence.add(new BroadcastEvidence(paragraph, quote));
}
if (evidence.size() == MAX_EVIDENCE) {
break;
}
}
}
String summary = groundedText(row.path("summary"), 500, chunk, source, evidence);
List<String> concerns = groundedItems(row.path("concerns"), chunk, source, evidence);
List<String> impacts = groundedItems(row.path("impacts"), chunk, source, evidence);
List<String> actions = groundedItems(row.path("actions"), chunk, source, evidence);
List<String> risks = groundedItems(row.path("risks"), chunk, source, evidence);
if (evidence.isEmpty()) {
return null;
}
return new BroadcastPerspective(
code,
label,
limited(row.path("summary").asText(), 500),
items(row.path("concerns")),
items(row.path("impacts")),
items(row.path("actions")),
items(row.path("risks")),
summary,
concerns,
impacts,
actions,
risks,
evidence
);
}
private static List<String> groundedItems(
JsonNode node,
SourceChunk chunk,
String source,
List<BroadcastEvidence> evidence
) {
if (!node.isArray()) {
return List.of();
}
Set<String> values = new LinkedHashSet<>();
for (JsonNode item : node) {
String value = groundedText(item, 300, chunk, source, evidence);
if (!value.isEmpty()) {
values.add(value);
}
if (values.size() == MAX_ITEMS) {
break;
}
}
return List.copyOf(values);
}
private static String groundedText(
JsonNode item,
int maximum,
SourceChunk chunk,
String source,
List<BroadcastEvidence> evidence
) {
if (!item.isObject()) {
return "";
}
String text = limited(item.path("text").asText(), maximum);
String quote = limited(item.path("evidenceQuote").asText(), 200);
if (text.isEmpty() || quote.length() < 20) {
return "";
}
int offset = source.indexOf(quote, chunk.startOffset());
if (offset < chunk.startOffset() || offset + quote.length() > chunk.endOffset()) {
return "";
}
boolean existing = evidence.stream().anyMatch(value -> value.quote().equals(quote));
if (!existing && evidence.size() == MAX_EVIDENCE) {
return "";
}
if (!existing) {
evidence.add(new BroadcastEvidence(paragraphIndex(source, offset), quote));
}
return text;
}
private static BroadcastPerspective persistedPerspective(JsonNode row) {
String code = clean(row.path("code").asText());
String label = ROLE_LABELS.get(code);
@@ -274,13 +320,18 @@ public class AihrBroadcastInsightService {
finance=财务;hr=人力;operations=业务运营;audit_risk=审计/风控;management=管理层。
返回格式:
{"perspectives":[{"code":"finance","summary":"...","concerns":["..."],"impacts":["..."],
"actions":["..."],"risks":["..."],"evidence":[{"paragraphIndex":1,"quote":"原文逐字短句"}]}]}
{"perspectives":[{"code":"finance",
"summary":{"text":"...","evidenceQuote":"原文逐字短句"},
"concerns":[{"text":"...","evidenceQuote":"原文逐字短句"}],
"impacts":[{"text":"...","evidenceQuote":"原文逐字短句"}],
"actions":[{"text":"...","evidenceQuote":"原文逐字短句"}],
"risks":[{"text":"...","evidenceQuote":"原文逐字短句"}]}]}
规则:
1. 只输出本切片明确涉及的岗位;没有直接内容就不要输出该岗位。
2. 每类最多 5 条,evidence 最多 3 条且每条 20 到 200 字。
3. 不得使用常识补充原文没有写的责任、时限、处罚或流程。
2. 每个 summary 或列表项都必须携带自己的 evidenceQuote;没有原文证据就删除该项。
3. 每类最多 5 条,证据短句 20 到 200 字。
4. 不得使用常识补充原文没有写的责任、时限、处罚或流程。
文件名:%s
切片:%d
@@ -366,6 +417,14 @@ public class AihrBroadcastInsightService {
}
private void add(BroadcastPerspective perspective) {
long newEvidence = perspective.evidence().stream()
.map(BroadcastEvidence::quote)
.filter(quote -> !evidence.containsKey(quote))
.distinct()
.count();
if (evidence.size() + newEvidence > MAX_EVIDENCE) {
return;
}
if (summary.isEmpty()) summary = perspective.summary();
add(concerns, perspective.concerns(), MAX_ITEMS);
add(impacts, perspective.impacts(), MAX_ITEMS);
@@ -645,7 +645,7 @@ public class AihrBroadcastService {
throw employeeOnly();
}
Long matched = jdbcTemplate.queryForObject("""
select count(*)
select count(distinct o.ext_party_id)
from sys_user u
join aihr_org_snapshot o
on binary o.tenant_id = binary u.tenant_id
@@ -655,9 +655,23 @@ public class AihrBroadcastService {
and u.user_type = ?
and u.status = '0'
and u.del_flag = '0'
and char_length(u.phonenumber) = 11
and u.phonenumber regexp '^[0-9]{11}$'
and o.employment_status = 'active'
and o.ext_party_id is not null
and o.ext_party_id <> ''
and not exists (
select 1
from aihr_org_snapshot o2
where binary o2.tenant_id = binary o.tenant_id
and binary o2.ext_party_id = binary o.ext_party_id
and o2.employment_status = 'active'
and o2.person_phone is not null
and o2.person_phone <> ''
and binary o2.person_phone <> binary u.phonenumber
)
""", Long.class, principal.tenantId(), principal.userId(), UserType.APP_USER.getUserType());
if (matched == null || matched == 0) {
if (matched == null || matched != 1) {
throw employeeOnly();
}
}
@@ -674,7 +688,32 @@ public class AihrBroadcastService {
and u.user_type = ?
and u.status = '0'
and u.del_flag = '0'
and char_length(u.phonenumber) = 11
and u.phonenumber regexp '^[0-9]{11}$'
and o.employment_status = 'active'
and o.ext_party_id is not null
and o.ext_party_id <> ''
and o.ext_party_id = (
select min(o2.ext_party_id)
from aihr_org_snapshot o2
where binary o2.tenant_id = binary u.tenant_id
and binary o2.person_phone = binary u.phonenumber
and o2.employment_status = 'active'
and o2.ext_party_id is not null
and o2.ext_party_id <> ''
group by o2.person_phone
having count(distinct o2.ext_party_id) = 1
)
and not exists (
select 1
from aihr_org_snapshot o3
where binary o3.tenant_id = binary o.tenant_id
and binary o3.ext_party_id = binary o.ext_party_id
and o3.employment_status = 'active'
and o3.person_phone is not null
and o3.person_phone <> ''
and binary o3.person_phone <> binary u.phonenumber
)
and o.position_name is not null
and o.position_name <> ''
order by o.position_name
@@ -2,17 +2,33 @@ package org.dromara.aihr.broadcast;
import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse;
import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest;
import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrBroadcastAttachmentContractTest {
@@ -58,5 +74,90 @@ class AihrBroadcastAttachmentContractTest {
assertTrue(source.contains("insight_status = 'PENDING'"));
assertTrue(source.contains("insight_status = 'FAILED'"));
assertTrue(source.contains("insight_status = 'PROCESSING'"));
assertTrue(source.contains("set insight_status = 'PENDING', insight_version = null"));
}
@Test
void insightWorkerUsesItsClaimTokenWhenCompletingWithoutChangingFileReadiness() throws Exception {
InsightJdbcTemplate jdbcTemplate = new InsightJdbcTemplate();
AihrBroadcastInsightService insightService = mock(AihrBroadcastInsightService.class);
when(insightService.generate(any(), any())).thenReturn(
new AihrBroadcastInsightService.Generation("FAILED", null, List.of()));
AihrBroadcastAttachmentService service = new AihrBroadcastAttachmentService(
jdbcTemplate,
mock(ISysOssService.class),
mock(KnowledgeDocumentParser.class),
mock(AihrSopSeedService.class),
insightService,
mock(AihrKnowledgePrincipalResolver.class),
mock(AihrBroadcastService.class),
mock(ScheduledExecutorService.class)
);
Method claim = AihrBroadcastAttachmentService.class.getDeclaredMethod("claimInsight");
claim.setAccessible(true);
Object work = claim.invoke(service);
Method claimTokenAccessor = work.getClass().getDeclaredMethod("claimToken");
claimTokenAccessor.setAccessible(true);
String claimToken = (String) claimTokenAccessor.invoke(work);
Method process = AihrBroadcastAttachmentService.class.getDeclaredMethod("processInsight", work.getClass());
process.setAccessible(true);
process.invoke(service, work);
assertTrue(claimToken.startsWith("claim-"));
assertArrayEquals(new Object[]{claimToken, "tenant-a", 9L}, jdbcTemplate.updateArgs.get(0));
assertTrue(jdbcTemplate.updateSql.get(1).contains("and insight_version = ?"));
assertFalse(jdbcTemplate.updateSql.get(1).contains("set status ="));
assertEquals(claimToken, jdbcTemplate.updateArgs.get(1)[5]);
}
private static final class InsightJdbcTemplate extends JdbcTemplate {
private final List<String> updateSql = new ArrayList<>();
private final List<Object[]> updateArgs = new ArrayList<>();
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
return rows(rowMapper);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper) {
return rows(rowMapper);
}
private static <T> List<T> rows(RowMapper<T> rowMapper) {
try {
return List.of(rowMapper.mapRow(resultSet(Map.of(
"id", 9L,
"tenant_id", "tenant-a",
"file_name", "制度.txt",
"extracted_text", "制度原文"
)), 0));
} catch (SQLException error) {
throw new AssertionError(error);
}
}
@Override
public int update(String sql, Object... args) {
updateSql.add(sql);
updateArgs.add(args);
return 1;
}
private static ResultSet resultSet(Map<String, Object> values) {
return (ResultSet) Proxy.newProxyInstance(
InsightJdbcTemplate.class.getClassLoader(), new Class<?>[]{ResultSet.class}, (proxy, method, args) -> {
Object value = values.get(args[0]);
if ("getLong".equals(method.getName())) {
return ((Number) value).longValue();
}
if ("getString".equals(method.getName())) {
return value == null ? null : value.toString();
}
throw new UnsupportedOperationException(method.getName());
});
}
}
}
@@ -39,22 +39,21 @@ class AihrBroadcastInsightServiceTest {
}
@Test
void fabricatedEvidenceIsRemovedBeforePersistence() {
String source = "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明。";
void everyConclusionRequiresItsOwnExactEvidenceAndUsesTheActualParagraph() {
String source = "本通知自发布之日起执行。\n\n财务部应在每月五日前完成费用复核并向项目负责人提交差异说明。";
AihrModelSeedService model = mock(AihrModelSeedService.class);
when(model.chat(any())).thenReturn(success("""
{
"perspectives": [{
"code": "finance",
"summary": "财务需要按月完成费用复核。",
"concerns": ["费用复核时限"],
"impacts": ["需形成差异说明"],
"actions": ["每月五日前完成复核"],
"risks": ["逾期影响项目核算"],
"evidence": [
{"paragraphIndex": 1, "quote": "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明"},
{"paragraphIndex": 1, "quote": "模型编造但原文不存在的财务处罚规则"}
]
"summary": {"text": "财务需要按月完成费用复核。",
"evidenceQuote": "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明"},
"concerns": [{"text": "逾期将被处罚",
"evidenceQuote": "模型编造但原文不存在的财务处罚规则"}],
"impacts": [{"text": "需形成差异说明",
"evidenceQuote": "财务部应在每月五日前完成费用复核并向项目负责人提交差异说明"}],
"actions": [],
"risks": []
}]
}
"""));
@@ -65,19 +64,41 @@ class AihrBroadcastInsightServiceTest {
assertEquals("READY", result.status());
assertEquals(1, result.perspectives().size());
assertEquals(1, result.perspectives().get(0).evidence().size());
assertEquals(2, result.perspectives().get(0).evidence().get(0).paragraphIndex());
assertTrue(result.perspectives().get(0).concerns().isEmpty());
assertEquals(List.of("需形成差异说明"), result.perspectives().get(0).impacts());
assertFalse(result.json().contains("模型编造"));
assertTrue(source.contains(result.perspectives().get(0).evidence().get(0).quote()));
}
@Test
void evidenceIsCheckedAgainstTheSameRedactedTextSentToTheModel() {
String source = "财务复核遇到疑问时请联系13912345678,并在每月五日前提交书面差异说明。";
AihrModelSeedService model = mock(AihrModelSeedService.class);
when(model.chat(any())).thenReturn(success("""
{"perspectives":[{"code":"finance",
"summary":{"text":"按时提交差异说明",
"evidenceQuote":"财务复核遇到疑问时请联系1391****5678,并在每月五日前提交书面差异说明。"},
"concerns":[],"impacts":[],"actions":[],"risks":[]}]}
"""));
AihrBroadcastInsightService service = new AihrBroadcastInsightService(new ObjectMapper(), model);
AihrBroadcastInsightService.Generation result = service.generate("费用通知.txt", source);
assertEquals("READY", result.status());
assertTrue(result.json().contains("1391****5678"));
assertFalse(result.json().contains("13912345678"));
}
@Test
void oneFailedChunkProducesPartialResultAndAllFailuresProduceFailed() {
String source = "财务部需要复核本月项目费用并形成书面差异说明。" + "甲".repeat(4_100);
AihrModelSeedService partialModel = mock(AihrModelSeedService.class);
when(partialModel.chat(any()))
.thenReturn(success("""
{"perspectives":[{"code":"finance","summary":"复核费用","concerns":[],"impacts":[],
"actions":[],"risks":[],"evidence":[{"paragraphIndex":1,
"quote":"财务部需要复核本月项目费用并形成书面差异说明"}]}]}
{"perspectives":[{"code":"finance",
"summary":{"text":"复核费用","evidenceQuote":"财务部需要复核本月项目费用并形成书面差异说明"},
"concerns":[],"impacts":[],"actions":[],"risks":[]}]}
"""))
.thenThrow(new IllegalStateException("provider timeout"));
AihrBroadcastInsightService partial = new AihrBroadcastInsightService(new ObjectMapper(), partialModel);
@@ -70,6 +70,22 @@ class AihrBroadcastServiceTest {
assertArrayEquals(new Object[]{"tenant-a", 7L, "app_user"}, jdbcTemplate.queryForObjectArgs);
}
@Test
void employeeBroadcastEndpointsFailClosedWhenOnePhoneMatchesMultiplePeople() {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
jdbcTemplate.audienceCount = 2L;
AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER);
ServiceException error = assertThrows(ServiceException.class, service::unreadCount);
assertEquals(HttpStatus.FORBIDDEN, error.getCode());
assertTrue(jdbcTemplate.queryForObjectSql.contains("count(distinct o.ext_party_id)"));
assertTrue(jdbcTemplate.queryForObjectSql.contains("not exists"));
assertTrue(jdbcTemplate.queryForObjectSql.contains("binary o2.person_phone <> binary u.phonenumber"));
assertTrue(jdbcTemplate.queryForObjectSql.contains("char_length(u.phonenumber) = 11"));
assertTrue(jdbcTemplate.queryForObjectSql.contains("u.phonenumber regexp '^[0-9]{11}$'"));
}
@Test
void unavailableMessagesDoNotLeakAcrossTenantsOrAfterWithdrawal() {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
+1 -1
View File
@@ -20,7 +20,7 @@
| SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 |
| 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写使用 `GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达。 |
| 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 |
| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。员工详情只返回摘要和受控下载信息,不返回提取全文或原始 OSS 地址;下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标用于“与我有关”和必读提醒,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 |
| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。文件提炼完成后异步生成生活顾问、保洁、保安、工程维修、财务、人力、运营、审计风控、管理层中有原文依据的岗位解读;员工详情附件返回 `insightStatus/defaultPerspectiveCode/perspectiveLabels/perspectives`,每项依据仅含原文段号与已校验的短引用,不返回提取全文或原始 OSS 地址。`defaultPerspectiveCode` 由服务端按当前 APP 手机号精确匹配在职岗位,客户端不能指定;所有有权限员工仍可查看并切换全部已生成视角。`PENDING/PARTIAL/FAILED` 均不阻断摘要、原文件下载、消息发布或追问。下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标在界面称“定向提醒”,用于必读与范围提示,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 |
| 员工直通车 `/pages/user/direct/index`、管理端 `/content/direct` | 员工 `GET /api/aihr/direct/channels`、`POST /api/aihr/direct/feedback`、`GET /api/aihr/direct/mine`、`GET /api/aihr/direct/mine/{id}`;处理端 `GET /api/aihr/direct/admin/feedback`、`POST /api/aihr/direct/admin/feedback/{id}/reply` | 员工可选择总裁、财务、人力、审计、运营并点对点提交;反馈内容支持语音转文字输入(复用 `/api/ai/asr`;文字为主路径,录音不可用或权限失败仅提示改文字输入)。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 |
| 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 |
+1 -1
View File
@@ -33,7 +33,7 @@
### 2026-07-24 公司消息 R1—R3 发布边界
当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。
当前公司消息能力包括全员公开频道、目标快照、必读、未读/已读、管理端发布/撤回、单文件异步提炼,以及以 `broadcastMessageId` 进入“问”的受控追问。开发分支已补充公司文件多岗位解读:AI 只保留能以原文短引用佐证的岗位视角,员工默认视角由认证手机号对应的在职岗位决定,同时允许切换查看全部已生成视角;生成失败不阻断摘要、原文件、发布或追问。服务端每轮重新校验租户、在职身份和消息状态,正文与附件内容均由服务端加载;消息撤回后续问和文件访问即被拒绝。该新增能力尚未部署或完成生产验收。
“问”页面将连续会话设为主体,消息/知识回答不再把通用培训或岗位资料伪造成当前员工待办。生产已验证全员文件消息发布、摘要、认证下载、文件内容追问和撤回;测试消息已撤回。直通车按高管、财务、人力、审计和运营五个通道分权,处理端只见“匿名员工”,系统保留内部身份审计,每条仅一次正式回复;生产已验证员工提交、审计处理和员工查看回复。定向人群正反例、正式处理人绑定、修订、补推、强触达和外部流程仍待补齐。
+2 -2
View File
@@ -110,7 +110,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验
@@ -242,7 +242,7 @@ GROUP BY d.dimension_code
ORDER BY d.dimension_code;
```
本批完整发布的预期是 `release-preflight` 的 `64/64` 必需表与列契约均存在,新增项为 `aihr_direct_feedback`、`aihr_broadcast_attachment`、`aihr_broadcast_message.attachment_id` 和 `aihr_agent_run`。此前 `57/57`、`62/62` 都只代表较窄的历史发布清单。后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,Agent 迁移也仍是本地发布候选,因此不得把完整清单写成已发布。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;结构、索引和内置内容通过只证明 schema/seed 与发布产物一致,不证明员工直达反馈、文件提炼、员工确认记忆、完整个人知识库或严格试点已经完成业务验收。
本批完整发布的预期是 `release-preflight` 的 `64/64` 必需表与列契约均存在,新增项为 `aihr_direct_feedback`、`aihr_broadcast_attachment`、`aihr_broadcast_message.attachment_id` 和 `aihr_agent_run`。此前 `57/57`、`62/62` 都只代表较窄的历史发布清单。后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,Agent 迁移也仍是本地发布候选,因此不得把完整清单写成已发布。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司文件附件字段和消息绑定字段契约为 `15/15`,并应存在 `(insight_status,status,update_time)` 解读队列索引;公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;结构、索引和内置内容通过只证明 schema/seed 与发布产物一致,不证明员工直达反馈、文件提炼、多岗位解读、员工确认记忆、完整个人知识库或严格试点已经完成业务验收。
## 迁移后应用回归
+3
View File
@@ -7,6 +7,7 @@ type ApiResult<T> = {
};
export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN';
export type BroadcastInsightStatus = 'NOT_PROCESSED' | 'PENDING' | 'READY' | 'PARTIAL' | 'FAILED';
export type BroadcastTargetRequest = {
projectCodes: string[];
@@ -51,6 +52,8 @@ export type BroadcastAttachment = {
summary?: string;
errorMessage?: string;
downloadUrl?: string;
insightStatus?: BroadcastInsightStatus;
perspectiveLabels?: string[];
};
export type BroadcastAdminPage = {
@@ -15,6 +15,12 @@ describe('公司文件提炼发布契约', () => {
expect(page).toContain('上传并提炼文件');
expect(page).toContain('refreshAttachment');
expect(page).toContain("attachment.value?.status !== 'READY'");
expect(api).toContain('insightStatus?: BroadcastInsightStatus');
expect(api).toContain('perspectiveLabels?: string[]');
expect(page).toContain('多岗位解读');
expect(page).toContain("response.data.insightStatus === 'PENDING'");
expect(page).toContain('attachment.perspectiveLabels?.length');
expect(page).toContain('刷新状态');
expect(page).toContain('attachmentId: form.attachmentId');
expect(pending).toContain('attachmentId?: number');
});
+35 -1
View File
@@ -166,6 +166,18 @@
show-icon
:title="`提炼摘要:${attachment.summary}`"
/>
<div v-if="attachment?.status === 'READY'" class="insight-status-line">
<span>多岗位解读:{{ insightStatusText(attachment.insightStatus) }}</span>
<div v-if="attachment.perspectiveLabels?.length" class="insight-labels">
<el-tag v-for="label in attachment.perspectiveLabels" :key="label" size="small" type="info">{{ label }}</el-tag>
</div>
<el-button
v-if="attachment.insightStatus === 'PENDING'"
link
type="primary"
@click="refreshAttachment(attachment.id)"
>刷新状态</el-button>
</div>
<el-alert
v-if="attachment?.status === 'FAILED'"
type="error"
@@ -673,6 +685,13 @@ const formatFileSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const insightStatusText = (status?: BroadcastAttachment['insightStatus']) => ({
NOT_PROCESSED: '未生成',
PENDING: '正在生成',
READY: '已完成',
PARTIAL: '部分完成',
FAILED: '生成失败,不影响发布'
}[status || 'NOT_PROCESSED']);
const stopAttachmentPolling = () => {
if (attachmentPollTimer !== undefined) window.clearTimeout(attachmentPollTimer);
@@ -685,7 +704,9 @@ const refreshAttachment = async (id: number, attemptsLeft = 80) => {
const response = await getBroadcastAttachment(id);
attachment.value = response.data;
form.attachmentId = response.data.id;
if ((response.data.status === 'QUEUED' || response.data.status === 'PROCESSING') && attemptsLeft > 0) {
if ((response.data.status === 'QUEUED'
|| response.data.status === 'PROCESSING'
|| response.data.insightStatus === 'PENDING') && attemptsLeft > 0) {
attachmentPollTimer = window.setTimeout(() => void refreshAttachment(id, attemptsLeft - 1), 1500);
}
} catch {
@@ -820,6 +841,19 @@ onUnmounted(stopAttachmentPolling);
font-size: 12px;
}
.insight-status-line,
.insight-labels {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.insight-status-line {
color: #606266;
font-size: 13px;
}
.table-card {
padding: 18px;
border-radius: 8px;
+108 -7
View File
@@ -14,10 +14,14 @@
<text class="message-title">{{ item.title }}</text>
<view class="detail-tags">
<text v-if="item.requiredRead" class="detail-tag required">必读消息</text>
<text v-if="item.targeted" class="detail-tag related">与我有关{{ item.targetReason ? ` · ${item.targetReason}` : '' }}</text>
<text v-if="item.targeted" class="detail-tag related">定向提醒{{ item.targetReason ? ` · ${item.targetReason}` : '' }}</text>
</view>
<text class="message-time">发布时间:{{ displayTime(item.publishedAt) }}</text>
<text class="message-content">{{ item.content }}</text>
<view class="detail-actions">
<button class="ask-message-button" @click="askAboutMessage">问这条消息</button>
<text class="ask-message-note">将在“问”中围绕本条消息继续咨询</text>
</view>
<view v-if="item.attachment" class="attachment-card">
<view class="attachment-head">
<view>
@@ -30,19 +34,67 @@
<button class="download-button" :disabled="downloading" @click="downloadAttachment">
{{ downloading ? '正在读取…' : '查看原文件' }}
</button>
</view>
<view class="detail-actions">
<button class="ask-message-button" @click="askAboutMessage">问这条消息</button>
<text class="ask-message-note">将在“问”中围绕本条消息继续咨询</text>
<view v-if="item.attachment.perspectives.length" class="perspective-panel">
<view class="perspective-heading">
<view>
<text class="perspective-kicker">
{{ selectedPerspective?.code === item.attachment.defaultPerspectiveCode ? '我的岗位视角' : '多岗位解读' }}
</text>
<text class="perspective-title">{{ selectedPerspective?.label }}</text>
</view>
<text v-if="item.attachment.insightStatus === 'PARTIAL'" class="insight-state partial">部分完成</text>
</view>
<scroll-view class="perspective-tabs" scroll-x>
<view class="perspective-tab-row" role="group" aria-label="岗位解读切换">
<button
v-for="perspective in item.attachment.perspectives"
:key="perspective.code"
class="perspective-tab"
:class="{ active: selectedPerspective?.code === perspective.code }"
:aria-pressed="selectedPerspective?.code === perspective.code"
@click="selectPerspective(perspective.code)"
>{{ perspective.label }}</button>
</view>
</scroll-view>
<text v-if="selectedPerspective?.summary" class="perspective-summary">{{ selectedPerspective.summary }}</text>
<view v-for="section in perspectiveSections" :key="section.label" class="perspective-section">
<text class="perspective-section-title">{{ section.label }}</text>
<text v-for="entry in section.items" :key="entry" class="perspective-item">· {{ entry }}</text>
</view>
<button
v-if="selectedPerspective?.evidence.length"
class="evidence-toggle"
:aria-expanded="evidenceExpanded"
@click="evidenceExpanded = !evidenceExpanded"
>
{{ evidenceExpanded ? '收起原文依据' : `查看原文依据(${selectedPerspective.evidence.length})` }}
</button>
<view v-if="evidenceExpanded" class="evidence-list">
<view v-for="evidence in selectedPerspective?.evidence" :key="`${evidence.paragraphIndex}-${evidence.quote}`" class="evidence-item">
<text class="evidence-index">第 {{ evidence.paragraphIndex }} 段</text>
<text class="evidence-quote">“{{ evidence.quote }}”</text>
</view>
</view>
</view>
<view v-else-if="item.attachment.insightStatus === 'PENDING'" class="insight-state-card" role="status">
<text>AI 正在从不同岗位解读,不影响查看原文件</text>
<button class="insight-refresh" @click="load">刷新解读</button>
</view>
<view v-else-if="item.attachment.insightStatus === 'FAILED'" class="insight-state-card failed">
多岗位解读暂不可用,可继续查看原文件或咨询
</view>
<view v-else-if="item.attachment.insightStatus === 'NOT_PROCESSED'" class="insight-state-card">
该文件暂未生成多岗位解读
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import type { BroadcastMessage } from '@/services/broadcast';
import type { BroadcastAttachment, BroadcastMessage } from '@/services/broadcast';
import {
clearBroadcastQuestionContext,
getBroadcastMessage,
@@ -60,6 +112,19 @@ const item = ref<BroadcastMessage | null>(null);
const loading = ref(false);
const message = ref('');
const downloading = ref(false);
const selectedPerspectiveCode = ref('');
const evidenceExpanded = ref(false);
const selectedPerspective = computed(() =>
item.value?.attachment?.perspectives.find((perspective) => perspective.code === selectedPerspectiveCode.value)
|| item.value?.attachment?.perspectives[0]
|| null
);
const perspectiveSections = computed(() => selectedPerspective.value ? [
{ label: '关注重点', items: selectedPerspective.value.concerns },
{ label: '岗位影响', items: selectedPerspective.value.impacts },
{ label: '需要行动', items: selectedPerspective.value.actions },
{ label: '风险提醒', items: selectedPerspective.value.risks }
].filter((section) => section.items.length) : []);
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.switchTab({ url: '/pages/user/today/index' }) });
const displayTime = (value: string) => value ? value.replace('T', ' ').slice(0, 16) : '';
@@ -70,6 +135,17 @@ const formatFileSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const selectPerspective = (code: string) => {
selectedPerspectiveCode.value = code;
evidenceExpanded.value = false;
};
const selectInitialPerspective = (attachment?: BroadcastAttachment) => {
const perspectives = attachment?.perspectives || [];
const preferred = perspectives.some((perspective) => perspective.code === attachment?.defaultPerspectiveCode)
? attachment?.defaultPerspectiveCode
: perspectives[0]?.code;
selectPerspective(preferred || '');
};
const downloadAttachment = async () => {
if (!item.value?.attachment || downloading.value) return;
@@ -130,6 +206,7 @@ const load = async () => {
message.value = '';
try {
const next = await getBroadcastMessage(id.value);
selectInitialPerspective(next.attachment);
item.value = next;
try {
await markBroadcastMessageRead(id.value);
@@ -179,6 +256,30 @@ onLoad((options) => {
.attachment-name { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; font-weight: 900; }
.attachment-size { flex: none; color: var(--muted); font-size: 12px; }
.attachment-summary { overflow-wrap: anywhere; color: #596273; font-size: 13px; line-height: 1.65; }
.perspective-panel { display: grid; gap: 12px; margin-top: 4px; padding-top: 14px; border-top: 1px solid #f0e3e5; }
.perspective-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.perspective-kicker { display: block; color: var(--brand); font-size: 11px; font-weight: 900; }
.perspective-title { display: block; margin-top: 3px; color: var(--ink); font-size: 17px; font-weight: 900; }
.insight-state { flex: none; padding: 3px 7px; border-radius: 999px; font-size: 11px; font-weight: 800; }
.insight-state.partial { background: #fff4df; color: #986000; }
.perspective-tabs { width: 100%; white-space: nowrap; }
.perspective-tab-row { display: inline-flex; gap: 8px; padding-bottom: 2px; }
.perspective-tab { display: inline-flex; min-height: 34px; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 999px; background: #fff; color: #596273; font-size: 12px; font-weight: 800; line-height: 1; }
.perspective-tab::after, .evidence-toggle::after { border: 0; }
.perspective-tab.active { border-color: #f0a6ac; background: #fff0f1; color: var(--brand); }
.perspective-summary { color: var(--ink); font-size: 14px; font-weight: 700; line-height: 1.65; }
.perspective-section { display: grid; gap: 5px; }
.perspective-section-title { color: #30343b; font-size: 13px; font-weight: 900; }
.perspective-item { overflow-wrap: anywhere; color: #596273; font-size: 13px; line-height: 1.55; }
.evidence-toggle { display: flex; min-height: 36px; align-items: center; justify-content: center; padding: 0; border-radius: 10px; background: #f4f5f7; color: #596273; font-size: 12px; font-weight: 800; }
.evidence-list { display: grid; gap: 8px; }
.evidence-item { display: grid; gap: 4px; padding: 10px; border-left: 3px solid #d9dde4; background: #f7f8fa; }
.evidence-index { color: #7a8290; font-size: 11px; font-weight: 800; }
.evidence-quote { overflow-wrap: anywhere; color: #596273; font-size: 12px; line-height: 1.55; }
.insight-state-card { display: grid; gap: 8px; padding: 10px 12px; border-radius: 10px; background: #f4f5f7; color: #596273; font-size: 12px; line-height: 1.55; }
.insight-state-card.failed { background: #fff4f4; color: #9d2730; }
.insight-refresh { display: flex; min-height: 34px; align-items: center; justify-content: center; padding: 0; border-radius: 9px; background: #fff; color: var(--brand); font-size: 12px; font-weight: 800; }
.insight-refresh::after { border: 0; }
.download-button { display: flex; min-height: 40px; align-items: center; justify-content: center; padding: 0; border: 1px solid #f0a6ac; border-radius: 12px; background: #fff; color: var(--brand); font-size: 13px; font-weight: 900; }
.download-button::after { border: 0; }
.detail-actions { display: grid; gap: 8px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--employee-line, #ebedf0); }
@@ -52,7 +52,7 @@
<view class="message-tags">
<text class="message-state" :class="{ read: item.read }">{{ item.read ? '已读' : '未读' }}</text>
<text v-if="item.requiredRead" class="message-tag required">必读</text>
<text v-if="item.targeted" class="message-tag related">与我有关</text>
<text v-if="item.targeted" class="message-tag related">定向提醒</text>
</view>
<text class="message-time">{{ displayTime(item.publishedAt) }}</text>
</view>
@@ -90,7 +90,7 @@ const moreAvailable = ref(false);
const scope = ref<BroadcastMessageScope>('ALL');
const scopeOptions: Array<{ value: BroadcastMessageScope; label: string }> = [
{ value: 'ALL', label: '全部' },
{ value: 'RELATED', label: '与我有关' },
{ value: 'RELATED', label: '定向提醒' },
{ value: 'REQUIRED_UNREAD', label: '必读未读' }
];
const requests = createPageRequestScope();
+34 -2
View File
@@ -4,6 +4,7 @@ export const broadcastQuestionContextStorageKey = 'aihr_broadcast_question_conte
export const broadcastQuestionContextTtlMs = 15 * 60 * 1000;
export type BroadcastMessageScope = 'ALL' | 'RELATED' | 'REQUIRED_UNREAD';
export type BroadcastInsightStatus = 'NOT_PROCESSED' | 'PENDING' | 'READY' | 'PARTIAL' | 'FAILED';
export interface BroadcastQuestionContext {
messageId: number;
@@ -26,6 +27,22 @@ export interface BroadcastMessage extends BroadcastMessageSummary {
attachment?: BroadcastAttachment;
}
export interface BroadcastEvidence {
paragraphIndex: number;
quote: string;
}
export interface BroadcastPerspective {
code: string;
label: string;
summary?: string;
concerns: string[];
impacts: string[];
actions: string[];
risks: string[];
evidence: BroadcastEvidence[];
}
export interface BroadcastAttachment {
id: number;
fileName: string;
@@ -34,6 +51,10 @@ export interface BroadcastAttachment {
status: 'READY';
summary?: string;
downloadUrl: string;
insightStatus: BroadcastInsightStatus;
defaultPerspectiveCode?: string;
perspectiveLabels: string[];
perspectives: BroadcastPerspective[];
}
export interface BroadcastMessagePage {
@@ -59,12 +80,23 @@ export const listBroadcastMessages = (pageNum = 1, pageSize = 20, scope: Broadca
timeout: 15000
});
export const getBroadcastMessage = (id: number) =>
apiRequest<BroadcastMessage>({
const normalizeBroadcastAttachment = (attachment: BroadcastAttachment): BroadcastAttachment => ({
...attachment,
insightStatus: attachment.insightStatus || 'NOT_PROCESSED',
perspectiveLabels: Array.isArray(attachment.perspectiveLabels) ? attachment.perspectiveLabels : [],
perspectives: Array.isArray(attachment.perspectives) ? attachment.perspectives : []
});
export const getBroadcastMessage = async (id: number) => {
const message = await apiRequest<BroadcastMessage>({
url: `/api/aihr/broadcast/messages/${id}`,
method: 'GET',
timeout: 15000
});
return message.attachment
? { ...message, attachment: normalizeBroadcastAttachment(message.attachment) }
: message;
};
export const markBroadcastMessageRead = (id: number) =>
apiRequest<void>({
+2 -1
View File
@@ -4,7 +4,7 @@ import test from 'node:test';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('M1 员工端可筛选与我有关和必读未读消息,并保留公开频道', async () => {
test('M1 员工端可筛选定向提醒和必读未读消息,并保留公开频道', async () => {
const [service, list, detail] = await Promise.all([
read('../src/services/broadcast.ts'),
read('../src/pages/user/broadcast/index.vue'),
@@ -19,6 +19,7 @@ test('M1 员工端可筛选与我有关和必读未读消息,并保留公开
assert.match(list, /listBroadcastMessages\(1, pageSize, scope\.value\)/);
assert.match(list, /item\.requiredRead/);
assert.match(list, /item\.targeted/);
assert.match(list, /定向提醒/);
assert.match(detail, /item\.requiredRead/);
assert.match(detail, /item\.targeted/);
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('公司文件按岗位展示可切换的解读、生成状态和可折叠原文依据', async () => {
const [service, list, detail] = await Promise.all([
read('../src/services/broadcast.ts'),
read('../src/pages/user/broadcast/index.vue'),
read('../src/pages/user/broadcast/detail.vue')
]);
assert.match(service, /export type BroadcastInsightStatus/);
assert.match(service, /interface BroadcastPerspective/);
assert.match(service, /defaultPerspectiveCode\?: string/);
assert.match(service, /perspectives: BroadcastPerspective\[\]/);
assert.match(service, /normalizeBroadcastAttachment/);
assert.match(service, /perspectives: Array\.isArray\(attachment\.perspectives\) \? attachment\.perspectives : \[\]/);
assert.match(service, /insightStatus: attachment\.insightStatus \|\| 'NOT_PROCESSED'/);
assert.match(detail, /多岗位解读/);
assert.match(detail, /defaultPerspectiveCode/);
assert.match(detail, /selectedPerspective/);
assert.match(detail, /selectPerspective/);
assert.match(detail, /原文依据/);
assert.match(detail, /insightStatus === 'PENDING'/);
assert.match(detail, /刷新解读/);
assert.match(detail, /insightStatus === 'PARTIAL'/);
assert.match(detail, /insightStatus === 'FAILED'/);
assert.match(detail, /scroll-x/);
assert.match(detail, /role="group"/);
assert.match(detail, /aria-pressed/);
assert.ok(detail.indexOf('attachment-summary') < detail.indexOf('perspective-panel'));
assert.ok(detail.indexOf('download-button') < detail.indexOf('perspective-panel'));
assert.ok(detail.indexOf('detail-actions') < detail.indexOf('perspective-panel'));
assert.match(list, /定向提醒/);
assert.doesNotMatch(list, />与我有关</);
assert.doesNotMatch(detail, />与我有关/);
});
+4
View File
@@ -609,9 +609,13 @@ contains backend/script/sql/update/aihr_20260715_candidate_interview_review_mysq
contains scripts/tests/aihr-schema-migrations.test.sh "AIHR legacy schema migrations are idempotent"
contains backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql "aihr_direct_feedback"
contains backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql "aihr_broadcast_attachment"
contains backend/script/sql/update/aihr_20260725_broadcast_multi_role_insight_mysql8.sql "insights_json"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java '"/api/aihr/direct"'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java '"/attachments"'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastInsightService.java "role-perspective-v1"
contains mobile-uni/src/pages/user/direct/index.vue "选择直达对象"
contains mobile-uni/src/pages/user/broadcast/detail.vue "多岗位解读"
contains frontend/src/views/content/broadcast.vue "多岗位解读"
contains frontend/src/views/content/direct.vue "直通车处理台"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "hireDate"
contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled'
+11 -5
View File
@@ -686,7 +686,7 @@ SELECT CONCAT(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_broadcast_attachment'
AND column_name IN ('tenant_id','message_id','oss_id','file_name','file_size','content_type','status','extracted_text','summary','error_message','uploaded_by')),
AND column_name IN ('tenant_id','message_id','oss_id','file_name','file_size','content_type','status','extracted_text','summary','insight_status','insights_json','insight_version','error_message','uploaded_by')),
'|',
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
@@ -697,14 +697,20 @@ SELECT CONCAT(
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_broadcast_message'
AND index_name = 'idx_aihr_broadcast_message_attachment'), '')
AND index_name = 'idx_aihr_broadcast_message_attachment'), ''),
'|',
COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'aihr_broadcast_attachment'
AND index_name = 'idx_aihr_broadcast_attachment_insight_queue'), '')
);
SQL
REMOTE
)" || fail "remote broadcast-attachment schema check failed: $remote_ssh:$remote_db"
[[ "$broadcast_attachment_contract" = "11|1|tenant_id,attachment_id" ]] \
|| fail "remote broadcast-attachment contract invalid: expected 11 attachment columns and indexed message attachment_id; got ${broadcast_attachment_contract:-missing}"
[[ "$broadcast_attachment_contract" = "14|1|tenant_id,attachment_id|insight_status,status,update_time" ]] \
|| fail "remote broadcast-attachment contract invalid: expected 14 attachment columns, indexed message attachment_id, and insight queue index; got ${broadcast_attachment_contract:-missing}"
local missing_work_assistant_columns
missing_work_assistant_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
set -euo pipefail
@@ -747,7 +753,7 @@ REMOTE
echo "remote_broadcast_target_recipient_uniqueness=3/3 $remote_ssh:$remote_db"
echo "remote_direct_feedback_contract=11/11 $remote_ssh:$remote_db"
echo "remote_direct_feedback_roles=5/5 $remote_ssh:$remote_db"
echo "remote_broadcast_attachment_contract=12/12 $remote_ssh:$remote_db"
echo "remote_broadcast_attachment_contract=15/15 $remote_ssh:$remote_db"
echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db"
echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db"
echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db"
+1
View File
@@ -42,6 +42,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_broadcast_multi_role_insight_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
-e "select count(*) as tables_count from information_schema.tables where table_schema = 'ry-vue';"