feat(personal): authorize enterprise knowledge from org ACL

This commit is contained in:
2026-07-12 15:45:46 +08:00
parent 3a3562abb4
commit 2161353247
8 changed files with 471 additions and 6 deletions
@@ -5,10 +5,7 @@ import org.dromara.aihr.personal.support.PersonalOwner;
import java.util.List;
import java.util.Optional;
/**
* Server-side enterprise knowledge grant. No default bean is provided: enterprise scope stays disabled until
* an authenticated organization/role policy is wired.
*/
/** Server-side enterprise knowledge grant resolved from trusted organization and ACL data. */
@FunctionalInterface
public interface EnterpriseKnowledgeAccessPolicy {
@@ -0,0 +1,152 @@
package org.dromara.aihr.personal.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.personal.support.PersonalOwner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Resolves enterprise knowledge grants from the authenticated user, the local organization snapshot and explicit
* enterprise knowledge ACL rows. Missing or broken identity/ACL data always denies access.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKnowledgeAccessPolicy {
private static final int MAX_FRAGMENT_GRANTS = 200;
private final JdbcTemplate jdbcTemplate;
@Override
public Optional<EnterpriseKnowledgeGrant> authorize(PersonalOwner owner) {
if (owner == null || owner.userId() <= 0 || isBlank(owner.tenantId())) {
return Optional.empty();
}
try {
Optional<String> phone = userPhone(owner);
if (phone.isEmpty()) {
return denied(owner, "user_phone_missing");
}
Optional<OrganizationIdentity> organization = organization(owner.tenantId(), phone.orElseThrow());
if (organization.isEmpty()) {
return denied(owner, "active_org_missing");
}
OrganizationIdentity identity = organization.orElseThrow();
List<Long> fragmentIds = authorizedFragmentIds(owner.tenantId(), identity);
if (fragmentIds.isEmpty()) {
return denied(owner, "acl_fragments_missing");
}
return Optional.of(new EnterpriseKnowledgeGrant(owner.tenantId(), owner.userId(),
identity.positionName(), fragmentIds));
} catch (RuntimeException ex) {
log.warn("enterprise_acl_denied tenant={} userId={} reason=db_error errorType={}",
safeTenant(owner), owner.userId(), ex.getClass().getSimpleName());
return Optional.empty();
}
}
private Optional<String> userPhone(PersonalOwner owner) {
List<String> phones = jdbcTemplate.query("""
SELECT phonenumber
FROM sys_user
WHERE BINARY tenant_id = BINARY ?
AND user_id = ?
AND status = '0'
AND del_flag = '0'
AND phonenumber IS NOT NULL
AND phonenumber <> ''
ORDER BY user_id
LIMIT 1
""", (rs, rowNum) -> rs.getString("phonenumber"), owner.tenantId(), owner.userId());
return phones.stream().map(String::trim).filter(value -> !value.isEmpty()).findFirst();
}
private Optional<OrganizationIdentity> organization(String tenantId, String phone) {
List<OrganizationIdentity> rows = jdbcTemplate.query("""
SELECT project_code, position_name, position_level
FROM aihr_org_snapshot
WHERE BINARY tenant_id = BINARY ?
AND person_phone = ?
AND employment_status = 'active'
AND project_code IS NOT NULL
AND project_code <> ''
AND position_name IS NOT NULL
AND position_name <> ''
ORDER BY snapshot_date DESC, id ASC
LIMIT 1
""", (rs, rowNum) -> new OrganizationIdentity(
trimmed(rs.getString("project_code")),
trimmed(rs.getString("position_name")),
trimmed(rs.getString("position_level"))), tenantId, phone);
return rows.stream().filter(OrganizationIdentity::valid).findFirst();
}
private List<Long> authorizedFragmentIds(String tenantId, OrganizationIdentity identity) {
String canonicalPosition = canonicalPosition(identity.positionName());
return jdbcTemplate.query("""
SELECT DISTINCT f.id AS fragment_id
FROM aihr_knowledge_acl a
JOIN aihr_knowledge_info i
ON i.id = a.knowledge_id
AND BINARY i.tenant_id = BINARY a.tenant_id
JOIN aihr_knowledge_fragment f
ON f.knowledge_id = a.knowledge_id
AND BINARY f.tenant_id = BINARY a.tenant_id
WHERE BINARY a.tenant_id = BINARY ?
AND a.enabled = 1
AND (
a.access_scope = 'TENANT'
OR (a.access_scope = 'PROJECT' AND a.project_code = ?)
OR (a.access_scope = 'POSITION'
AND (a.project_code IS NULL OR a.project_code = '' OR a.project_code = ?)
AND a.position_name = ?
AND (a.position_level IS NULL OR a.position_level = '' OR a.position_level = ?))
)
ORDER BY f.id ASC
LIMIT 200
""", (rs, rowNum) -> rs.getLong("fragment_id"), tenantId, identity.projectCode(),
identity.projectCode(), canonicalPosition, identity.positionLevel()).stream()
.filter(id -> id != null && id > 0)
.distinct()
.sorted()
.limit(MAX_FRAGMENT_GRANTS)
.toList();
}
private Optional<EnterpriseKnowledgeGrant> denied(PersonalOwner owner, String reason) {
log.warn("enterprise_acl_denied tenant={} userId={} reason={}", safeTenant(owner), owner.userId(), reason);
return Optional.empty();
}
static String canonicalPosition(String position) {
String normalized = trimmed(position).replaceAll("\\s+", "").toLowerCase(Locale.ROOT);
return switch (normalized) {
case "生活顾问", "物业管家", "客服管家" -> "生活顾问";
default -> trimmed(position);
};
}
private static String safeTenant(PersonalOwner owner) {
return owner == null || owner.tenantId() == null ? "unknown" : owner.tenantId();
}
private static String trimmed(String value) {
return value == null ? "" : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record OrganizationIdentity(String projectCode, String positionName, String positionLevel) {
private boolean valid() {
return !projectCode.isBlank() && !positionName.isBlank();
}
}
}
@@ -0,0 +1,246 @@
package org.dromara.aihr.personal;
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy;
import org.dromara.aihr.personal.service.OrgSnapshotEnterpriseKnowledgeAccessPolicy;
import org.dromara.aihr.personal.support.PersonalOwner;
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 org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AssignableTypeFilter;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.LongStream;
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.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest {
@Test
void activeOrganizationMemberReceivesOnlyAclAuthorizedFragments() {
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate();
jdbc.phone = "13900000103";
jdbc.organization = Map.of(
"project_code", "PRJ-FCW",
"position_name", "物业管家",
"position_level", "一线"
);
jdbc.fragmentIds = List.of(100101L, 100201L, 100301L);
var policy = new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc);
var grant = policy.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
assertEquals("000000", grant.tenantId());
assertEquals(103L, grant.userId());
assertEquals("物业管家", grant.position());
assertEquals(List.of(100101L, 100201L, 100301L), grant.allowedFragmentIds());
assertTrue(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_knowledge_acl")));
}
@Test
void policyIsTheSingleSpringProductionImplementation() {
var scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AssignableTypeFilter(EnterpriseKnowledgeAccessPolicy.class));
var implementations = scanner.findCandidateComponents("org.dromara.aihr")
.stream().map(definition -> definition.getBeanClassName()).toList();
assertEquals(List.of(OrgSnapshotEnterpriseKnowledgeAccessPolicy.class.getName()), implementations);
}
@Test
void missingPhoneOrganizationOrAclFailsClosed() {
PersonalOwner owner = new PersonalOwner("000000", 103L, null);
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(new RecordingJdbcTemplate())
.authorize(owner).isEmpty());
RecordingJdbcTemplate withoutOrg = new RecordingJdbcTemplate();
withoutOrg.phone = "13900000103";
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(withoutOrg).authorize(owner).isEmpty());
RecordingJdbcTemplate withoutAcl = fixture();
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(withoutAcl).authorize(owner).isEmpty());
}
@Test
void organizationLookupRequiresSameTenantAndActiveEmployment() {
RecordingJdbcTemplate jdbc = fixture();
jdbc.expectedTenant = "000000";
jdbc.fragmentIds = List.of(100101L);
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("999999", 103L, null)).isEmpty());
assertFalse(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_org_snapshot")));
jdbc = fixture();
jdbc.organization = null;
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).isEmpty());
assertTrue(jdbc.sql.stream().anyMatch(value -> value.contains("employment_status = 'active'")));
}
@Test
void aclQueryEnforcesTenantProjectPositionAndTenantScopes() {
RecordingJdbcTemplate jdbc = fixture();
jdbc.fragmentIds = List.of(100101L);
new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
String aclSql = jdbc.sql.stream().filter(value -> value.contains("FROM aihr_knowledge_acl"))
.findFirst().orElseThrow();
assertTrue(aclSql.contains("a.enabled = 1"));
assertTrue(aclSql.contains("a.access_scope = 'TENANT'"));
assertTrue(aclSql.contains("a.access_scope = 'PROJECT' AND a.project_code = ?"));
assertTrue(aclSql.contains("a.access_scope = 'POSITION'"));
assertTrue(aclSql.contains("JOIN aihr_knowledge_fragment"));
assertEquals(List.of("000000", "PRJ-FCW", "PRJ-FCW", "生活顾问", "一线"),
jdbc.args.get(jdbc.args.size() - 1));
}
@Test
void positionAliasesAreResolvedOnlyOnServer() {
for (String position : List.of("生活顾问", "物业管家", "客服管家")) {
RecordingJdbcTemplate jdbc = fixture();
jdbc.organization = Map.of(
"project_code", "PRJ-FCW",
"position_name", position,
"position_level", "一线"
);
jdbc.fragmentIds = List.of(100101L);
new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
assertEquals("生活顾问", jdbc.args.get(jdbc.args.size() - 1).get(3));
}
}
@Test
void organizationValuesStayBoundParametersInsteadOfSqlText() {
RecordingJdbcTemplate jdbc = fixture();
String maliciousProject = "PRJ' OR 1=1 --";
String maliciousPosition = "生活顾问' OR 1=1 --";
jdbc.organization = Map.of(
"project_code", maliciousProject,
"position_name", maliciousPosition,
"position_level", "一线' OR 1=1 --"
);
jdbc.fragmentIds = List.of(100101L);
new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
String aclSql = jdbc.sql.get(jdbc.sql.size() - 1);
assertFalse(aclSql.contains(maliciousProject));
assertFalse(aclSql.contains(maliciousPosition));
assertTrue(jdbc.args.get(jdbc.args.size() - 1).contains(maliciousProject));
assertTrue(jdbc.args.get(jdbc.args.size() - 1).contains(maliciousPosition));
}
@Test
void fragmentGrantIsStableDistinctAndCappedAtTwoHundred() {
RecordingJdbcTemplate jdbc = fixture();
List<Long> ids = new ArrayList<>(LongStream.rangeClosed(1, 250).boxed().toList());
ids.add(1L);
jdbc.fragmentIds = ids;
var grant = new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
assertEquals(200, grant.allowedFragmentIds().size());
assertEquals(1L, grant.allowedFragmentIds().get(0));
assertEquals(200L, grant.allowedFragmentIds().get(199));
}
@Test
void databaseFailureFailsClosed() {
RecordingJdbcTemplate jdbc = fixture();
jdbc.fail = true;
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).isEmpty());
}
private static RecordingJdbcTemplate fixture() {
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate();
jdbc.phone = "13900000103";
jdbc.organization = Map.of(
"project_code", "PRJ-FCW",
"position_name", "物业管家",
"position_level", "一线"
);
return jdbc;
}
private static final class RecordingJdbcTemplate extends JdbcTemplate {
private final List<String> sql = new ArrayList<>();
private final List<List<Object>> args = new ArrayList<>();
private String phone;
private String expectedTenant;
private Map<String, String> organization;
private List<Long> fragmentIds = List.of();
private boolean fail;
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
this.sql.add(sql);
this.args.add(List.of(args));
if (fail) {
throw new DataAccessResourceFailureException("database unavailable");
}
if (sql.contains("FROM sys_user")) {
if (expectedTenant != null && !expectedTenant.equals(args[0])) {
return List.of();
}
return phone == null ? List.of() : mapRows(rowMapper, List.of(Map.of("phonenumber", phone)));
}
if (sql.contains("FROM aihr_org_snapshot")) {
return organization == null ? List.of() : mapRows(rowMapper, List.of(organization));
}
if (sql.contains("FROM aihr_knowledge_acl")) {
return fragmentIds.stream().map(id -> mapRow(rowMapper, Map.of("fragment_id", id))).toList();
}
return List.of();
}
private static <T> List<T> mapRows(RowMapper<T> mapper, List<Map<String, ?>> rows) {
List<T> result = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) {
result.add(mapRow(mapper, rows.get(index), index));
}
return result;
}
private static <T> T mapRow(RowMapper<T> mapper, Map<String, ?> row) {
return mapRow(mapper, row, 0);
}
private static <T> T mapRow(RowMapper<T> mapper, Map<String, ?> row, int rowNum) {
try {
ResultSet resultSet = mock(ResultSet.class);
for (Map.Entry<String, ?> entry : row.entrySet()) {
Object value = entry.getValue();
when(resultSet.getString(entry.getKey())).thenReturn(value == null ? null : value.toString());
if (value instanceof Number number) {
when(resultSet.getLong(entry.getKey())).thenReturn(number.longValue());
}
}
return mapper.mapRow(resultSet, rowNum);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
}
@@ -16,6 +16,32 @@ class PersonalSchemaContractTest {
private static final String SCHEMA_FILE = "aihr_personal_knowledge_mysql8.sql";
@Test
void enterpriseKnowledgeAclIsIndependentAndDefaultDeny() throws IOException {
Path projectRoot = locateProjectRoot();
String sql = Files.readString(projectRoot.resolve("backend/script/sql/aihr_knowledge_mysql8.sql"))
.toLowerCase(Locale.ROOT);
String acl = tableDefinition(sql, "aihr_knowledge_acl");
assertTrue(acl.contains("`tenant_id` varchar(20) not null"));
assertTrue(acl.contains("`knowledge_id` bigint not null"));
assertTrue(acl.contains("`access_scope` varchar(20) not null"));
assertTrue(acl.contains("`project_code` varchar(50) default null"));
assertTrue(acl.contains("`position_name` varchar(100) default null"));
assertTrue(acl.contains("`position_level` varchar(30) default null"));
assertTrue(acl.contains("`classification` varchar(20) not null default 'internal'"));
assertTrue(acl.contains("`enabled` tinyint(1) not null default 1"));
assertTrue(acl.contains("key `idx_aihr_knowledge_acl_lookup` (`tenant_id`, `enabled`, `access_scope`)"));
assertTrue(acl.contains("unique key `uk_aihr_knowledge_acl_rule`"));
assertTrue(sql.contains("(11001, '000000', 1001, 'position'"));
assertTrue(sql.contains("(11002, '000000', 1002, 'position'"));
assertTrue(sql.contains("(11003, '000000', 1003, 'position'"));
assertTrue(sql.contains("'生活顾问', '一线', 'internal', 1"));
assertFalse(sql.contains("'tenant', null, null, null, 'internal', 1"),
"Seed SOP knowledge must not be tenant-wide");
}
@Test
void personalKnowledgeSchemaIsOwnerScopedAndIndependent() throws IOException {
Path projectRoot = locateProjectRoot();
@@ -68,6 +68,26 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_fragment` (
FULLTEXT KEY `ft_aihr_knowledge_fragment_content` (`content`) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识片段';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_acl` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`knowledge_id` bigint NOT NULL COMMENT '企业知识库ID',
`access_scope` varchar(20) NOT NULL COMMENT '授权范围:TENANT/PROJECT/POSITION',
`project_code` varchar(50) DEFAULT NULL COMMENT '项目编码,PROJECT必填,POSITION可选',
`position_name` varchar(100) DEFAULT NULL COMMENT '规范岗位名称,POSITION必填',
`position_level` varchar(30) DEFAULT NULL COMMENT '岗位层级,POSITION可选',
`classification` varchar(20) NOT NULL DEFAULT 'INTERNAL' COMMENT '知识密级:INTERNAL/RESTRICTED',
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`rule_key` varchar(220) GENERATED ALWAYS AS
(concat(`access_scope`, '|', ifnull(`project_code`, ''), '|', ifnull(`position_name`, ''), '|', ifnull(`position_level`, ''))) STORED,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_knowledge_acl_rule` (`tenant_id`, `knowledge_id`, `rule_key`),
KEY `idx_aihr_knowledge_acl_lookup` (`tenant_id`, `enabled`, `access_scope`),
KEY `idx_aihr_knowledge_acl_knowledge` (`tenant_id`, `knowledge_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='企业知识访问控制,默认无ACL不可见';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_import_task` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
@@ -125,6 +145,17 @@ ON DUPLICATE KEY UPDATE
`content` = VALUES(`content`),
`update_time` = NOW();
INSERT INTO `aihr_knowledge_acl`
(`id`, `tenant_id`, `knowledge_id`, `access_scope`, `project_code`, `position_name`, `position_level`, `classification`, `enabled`, `create_time`, `update_time`)
VALUES
(11001, '000000', 1001, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()),
(11002, '000000', 1002, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()),
(11003, '000000', 1003, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
`classification` = VALUES(`classification`),
`enabled` = VALUES(`enabled`),
`update_time` = NOW();
-- 知识库批量上传队列:上传秒回后由后台 worker 加工,支持单文件重试
CREATE TABLE IF NOT EXISTS `aihr_knowledge_upload_item` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+1 -1
View File
@@ -174,7 +174,7 @@ curl -fsS -X POST "$API_BASE/api/knowledge/doc/rebuild-vector-index" -H "Authori
个人知识接口统一位于 `/api/aihr/personal-assistant`,包括空间、资料列表与详情、文字/文件/URL 收藏、重试、删除、下载链接、检索、问答和会话。所有接口必须使用移动端短信登录返回的 `Authorization: Bearer <access_token>` 与 `clientid`,服务端从登录态注入 `tenant_id + owner_user_id`;请求体和查询参数不接受调用方指定 owner。
`POST /search` 与 `POST /ask` 的 `scope` 可选 `PERSONAL`、`ENTERPRISE` 或二者组合。返回引用的 `domain` 必须明确为个人或企业域;企业域只有在服务端企业知识授权策略返回显式 fragment allowlist 后才会参与 mixed 检索,未配置授权时 fail-closed。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。
`POST /search` 与 `POST /ask` 的 `scope` 可选 `PERSONAL`、`ENTERPRISE` 或二者组合。返回引用的 `domain` 必须明确为个人或企业域。企业域授权只读取登录用户的 `sys_user` 手机号、同租户在职 `aihr_org_snapshot` 和启用的 `aihr_knowledge_acl`,按 `TENANT / PROJECT / POSITION` 规则在 SQL 层生成 fragment allowlist;无组织身份或 ACL 时 fail-closed。客户端不能提交岗位、项目或 fragmentId 扩权。当前是阶段二组织快照消费,不表示阶段三北森实时对接完成。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。
URL 收藏在任何网络请求前执行 SSRF 校验,私网、环回、链路本地和云元数据地址返回 `PERSONAL_URL_BLOCKED`。删除先进入 `DELETING` 并立即对详情和检索隐藏,再异步清理 Qdrant、片段和 OSS;重复删除返回同一个 `cleanupJobId`。
+2
View File
@@ -198,6 +198,8 @@ curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p'
个人知识使用独立 Qdrant collection,默认 `aihr_personal_knowledge`,payload 强制包含 `tenant_id`、`owner_user_id`、`item_id` 与 `captured_at`。对象存储固定使用 `personal-minio` 配置和私有 bucket `ruoyi-personal`,不修改企业资料使用的默认 `minio/ruoyi`。MySQL 表和专用 OSS 配置由 `backend/script/sql/aihr_personal_knowledge_mysql8.sql` 初始化;旧开发库先运行 `COMPOSE_PROJECT_NAME=wygj ./scripts/reset-dev-db.sh`。
企业范围问答另外依赖 `aihr_org_snapshot` 与 `aihr_knowledge_acl`。reset 会给企业 SOP `1001/1002/1003` 写入“一线生活顾问”POSITION ACL;手机号 `13900000103` 的组织快照岗位为物业管家,服务端按固定别名映射到生活顾问后获得 allowlist。普通新注册手机号没有组织快照,企业范围必须返回无权限。不要用手工手机号、客户端岗位参数或 TENANT ACL 绕过该默认拒绝;开放组织系统只负责刷新阶段二快照,北森实时组织/任职仍属阶段三。
空间与处理配额通过 `aihr.personal.*` 配置覆盖,包括空间字节配额、资料数量、单文件大小、抓取字节/超时、worker 与 cleanup 批量大小。不要把个人 collection 改回企业 `aihr_knowledge`。
启动依赖与后端后运行真实隔离 smoke:
+12 -1
View File
@@ -124,10 +124,21 @@ org.dromara.aihr.knowledge.parse
5. 个人资料转企业知识必须复制经审核后的脱敏版本,不能把个人 item 直接改成企业 scope。
6. 自动化测试必须使用两个用户交叉访问,验证列表、详情、检索、向量和删除均返回不可见。
企业知识授权由唯一生产策略 `OrgSnapshotEnterpriseKnowledgeAccessPolicy` 负责:只用登录态
`tenant_id + user_id` 查询本人 `sys_user.phonenumber`,再匹配同租户、在职的 `aihr_org_snapshot`,
最后按 `aihr_knowledge_acl` 的 `TENANT / PROJECT / POSITION` 规则生成 fragment allowlist。岗位别名
`生活顾问 / 物业管家 / 客服管家` 只在服务端归一化;无组织快照、离职、无启用 ACL、表缺失或数据库故障均默认拒绝。
这里消费的是阶段二本地/定时同步快照,不代表阶段三北森实时对接已经完成。
## 4. 数据模型
初始化 SQL:`backend/script/sql/aihr_personal_knowledge_mysql8.sql`。
企业 ACL 独立位于 `backend/script/sql/aihr_knowledge_mysql8.sql` 的 `aihr_knowledge_acl`,不向个人表添加
企业 scope。ACL 以租户和 knowledge 为边界,可按租户、项目或岗位授权,并保留 `INTERNAL / RESTRICTED`
密级字段;没有启用 ACL 的企业知识默认不可见。当前 seed 仅将 SOP `1001/1002/1003` 明确授权给一线
生活顾问岗位,没有全租户放开。
### 4.1 `aihr_personal_space`
每个租户内每名员工一条。
@@ -518,7 +529,7 @@ src/services/personal-assistant.ts API 客户端
### M3:企业知识授权融合
- 企业知识权限过滤。
- 企业知识权限过滤:组织快照 + 独立 ACL + SQL fragment allowlist 下推,默认拒绝。
- 个人/企业/mixed 三范围切换和引用域标记。
### M4:移动端闭环与试点