diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java index 2fe6c505..f939436e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java @@ -6,8 +6,9 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.SearchHitResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.SearchScope; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.aihr.personal.support.PersonalOwner; -import org.springframework.beans.factory.ObjectProvider; +import org.dromara.common.core.exception.ServiceException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -58,6 +59,7 @@ public class PersonalRetrievalService { if (!validated.personalScope()) { return List.of(); } + requireOwnedReadyItems(owner, validated.itemIds()); List fulltext; try { @@ -235,6 +237,23 @@ public class PersonalRetrievalService { args.addAll(itemIds); } + private void requireOwnedReadyItems(PersonalOwner owner, List itemIds) { + if (itemIds.isEmpty()) return; + StringBuilder sql = new StringBuilder(""" + select count(*) from aihr_personal_item + where binary tenant_id = binary ? and owner_user_id = ? and status = 'READY' and id in ( + """); + sql.append("?,".repeat(itemIds.size())); + sql.setLength(sql.length() - 1); + sql.append(")"); + List args = new ArrayList<>(); + args.add(owner.tenantId()); + args.add(owner.userId()); + args.addAll(itemIds); + Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + if (count == null || count != itemIds.size()) throw new ServiceException("PERSONAL_ITEM_NOT_FOUND"); + } + private static void validateDates(LocalDate dateFrom, LocalDate dateTo) { if (dateFrom != null && dateTo != null && dateFrom.isAfter(dateTo)) { throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_INVALID"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java index 8f74e5f7..41854ff6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java @@ -19,8 +19,8 @@ public class PersonalKnowledgeProperties { private String qdrantApiKey = ""; private int qdrantTimeoutSeconds = 3; private int retrievalLimit = 10; - /** Optional sys_oss_config key. Blank selects the system default client. */ - private String ossConfigKey = ""; + /** Dedicated private sys_oss_config key; never fall back to the shared public bucket. */ + private String ossConfigKey = "personal-minio"; private int chunkSize = 800; private int chunkOverlap = 120; private int parsingLeaseMinutes = 15; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java index 194705e3..bc392c33 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java @@ -52,6 +52,12 @@ import static org.mockito.Mockito.when; @Tag("dev") class PersonalIngestionServiceTest { + @Test + void personalStorageDefaultsToDedicatedPrivateConfigKey() { + PersonalKnowledgeProperties properties = new PersonalKnowledgeProperties(); + assertEquals("personal-minio", properties.getOssConfigKey()); + } + private static final PersonalOwner OWNER = new PersonalOwner("000000", 101L, "ext-101"); @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java index 0e45224c..524baf79 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java @@ -7,6 +7,7 @@ import org.dromara.aihr.personal.service.PersonalRetrievalService; import org.dromara.aihr.personal.service.PersonalVectorStore; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.aihr.personal.support.PersonalOwner; +import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Tag; import org.mockito.ArgumentCaptor; @@ -31,6 +32,7 @@ class PersonalRetrievalServiceTest { @Test void fulltextSqlPreservesOwnerJoinFiltersDatesAndPreparedItemIds() { JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(2L); when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of()); PersonalRetrievalService service = service(jdbc, query -> java.util.Optional.empty(), vectorStore(List.of())); @@ -57,6 +59,7 @@ class PersonalRetrievalServiceTest { @Test void excludesPersonalScopeAndRejectsInvalidRequests() { JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L); PersonalRetrievalService service = service(jdbc, query -> java.util.Optional.empty(), vectorStore(List.of())); PersonalOwner owner = new PersonalOwner("t", 1, null); assertTrue(service.search(owner, new PersonalSearchRequest("q", List.of(SearchScope.ENTERPRISE), null, null, null, 10)).isEmpty()); @@ -71,6 +74,7 @@ class PersonalRetrievalServiceTest { @Test void vectorHydrationRechecksOwnerAndReadyAndRrfDedupesDeterministically() { JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L); SearchHitResponse lexical = hit("10", "Lexical"); SearchHitResponse vector = hit("20", "Vector"); when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) @@ -110,6 +114,48 @@ class PersonalRetrievalServiceTest { verify(jdbc, times(1)).query(anyString(), any(RowMapper.class), any(Object[].class)); } + @Test + void itemIdsFailClosedWhenAnyRequestedItemIsNotOwnedAndReady() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L); + PersonalRetrievalService service = service(jdbc, query -> Optional.empty(), vectorStore(List.of())); + + ServiceException error = assertThrows(ServiceException.class, () -> service.search( + new PersonalOwner("tenant-a", 7, null), + new PersonalSearchRequest("隔离", List.of(SearchScope.PERSONAL), null, null, List.of(10L, 11L), 10))); + + assertEquals("PERSONAL_ITEM_NOT_FOUND", error.getMessage()); + verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class)); + } + + @Test + void sameItemIdIsVisibleToOwnerAAndRejectedForOwnerB() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L, 0L); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of()); + PersonalRetrievalService service = service(jdbc, query -> Optional.empty(), vectorStore(List.of())); + PersonalSearchRequest request = new PersonalSearchRequest( + "隔离", List.of(SearchScope.PERSONAL), null, null, List.of(10L), 10); + + assertDoesNotThrow(() -> service.search(new PersonalOwner("tenant-a", 7, null), request)); + ServiceException error = assertThrows(ServiceException.class, + () -> service.search(new PersonalOwner("tenant-a", 8, null), request)); + + assertEquals("PERSONAL_ITEM_NOT_FOUND", error.getMessage()); + } + + @Test + void searchWithoutItemIdsRemainsOwnerScopedWithoutPreflightLookup() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of()); + PersonalRetrievalService service = service(jdbc, query -> Optional.empty(), vectorStore(List.of())); + + assertDoesNotThrow(() -> service.search(new PersonalOwner("tenant-a", 7, null), + new PersonalSearchRequest("隔离", List.of(SearchScope.PERSONAL), null, null, null, 10))); + + verify(jdbc, never()).queryForObject(anyString(), eq(Long.class), any(Object[].class)); + } + @Test void fulltextFailureStillAllowsScopedVectorHydration() { JdbcTemplate jdbc = mock(JdbcTemplate.class); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java index 372ea011..83ee0db5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java @@ -117,7 +117,7 @@ class PersonalSpaceServiceTest { assertEquals(1000, properties.getMaxItems()); assertEquals(5, properties.getDownloadUrlMinutes()); assertEquals("aihr_personal_knowledge", properties.getQdrantCollection()); - assertEquals("", properties.getOssConfigKey()); + assertEquals("personal-minio", properties.getOssConfigKey()); assertEquals(800, properties.getChunkSize()); assertEquals(120, properties.getChunkOverlap()); assertEquals(15, properties.getParsingLeaseMinutes()); diff --git a/backend/script/sql/aihr_personal_knowledge_mysql8.sql b/backend/script/sql/aihr_personal_knowledge_mysql8.sql index eddd6c89..7358cdee 100644 --- a/backend/script/sql/aihr_personal_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_personal_knowledge_mysql8.sql @@ -1,10 +1,21 @@ -- 个人 AI 助理独立知识空间。 -- 个人知识仅按 tenant_id + owner_user_id 隔离,不修改或复用企业 aihr_knowledge_* 表。 --- 个人资料禁止使用公开桶;本地默认 MinIO 改为私有访问,下载统一走短时签名 URL。 -UPDATE `sys_oss_config` -SET `access_policy` = '0', `update_time` = CURRENT_TIMESTAMP -WHERE `tenant_id` = '000000' AND `config_key` = 'minio'; +-- 个人资料使用独立私有桶;不得修改企业知识使用的默认 MinIO 配置。 +INSERT INTO `sys_oss_config` + (`oss_config_id`, `tenant_id`, `config_key`, `access_key`, `secret_key`, `bucket_name`, `prefix`, + `endpoint`, `domain`, `is_https`, `region`, `access_policy`, `status`, `ext1`, `create_dept`, + `create_by`, `create_time`, `update_by`, `update_time`, `remark`) +SELECT 9001, source.`tenant_id`, 'personal-minio', source.`access_key`, source.`secret_key`, + 'ruoyi-personal', 'personal', source.`endpoint`, source.`domain`, source.`is_https`, source.`region`, + '0', '1', '', source.`create_dept`, source.`create_by`, CURRENT_TIMESTAMP, + source.`update_by`, CURRENT_TIMESTAMP, '个人AI助理专用私有对象存储' +FROM `sys_oss_config` source +WHERE source.`tenant_id` = '000000' AND source.`config_key` = 'minio' + AND NOT EXISTS ( + SELECT 1 FROM `sys_oss_config` target + WHERE target.`tenant_id` = source.`tenant_id` AND target.`config_key` = 'personal-minio' + ); CREATE TABLE IF NOT EXISTS `aihr_personal_space` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 66180e71..f02cbf64 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -74,4 +74,5 @@ services: entrypoint: ["/bin/sh", "-c"] command: > "mc alias set local http://minio:9000 ruoyi ruoyi123 && - mc mb -p local/ruoyi || true" + mc mb -p local/ruoyi || true; + mc mb -p local/ruoyi-personal || true" diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 28149578..d3e7225d 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -177,3 +177,5 @@ curl -fsS -X POST "$API_BASE/api/knowledge/doc/rebuild-vector-index" -H "Authori `POST /search` 与 `POST /ask` 的 `scope` 可选 `PERSONAL`、`ENTERPRISE` 或二者组合。返回引用的 `domain` 必须明确为个人或企业域;企业域只有在服务端企业知识授权策略返回显式 fragment allowlist 后才会参与 mixed 检索,未配置授权时 fail-closed。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。 URL 收藏在任何网络请求前执行 SSRF 校验,私网、环回、链路本地和云元数据地址返回 `PERSONAL_URL_BLOCKED`。删除先进入 `DELETING` 并立即对详情和检索隐藏,再异步清理 Qdrant、片段和 OSS;重复删除返回同一个 `cleanupJobId`。 + +个人资料对象只写 `personal-minio` 对应的私有 bucket `ruoyi-personal`,下载使用短时签名 URL;默认 `minio/ruoyi` 仍保留企业资料原有策略。`POST /search` 一旦携带任一非本人或非 READY 的 `itemIds`,整个请求 fail-closed 返回 `PERSONAL_ITEM_NOT_FOUND`,不以空结果掩盖越权。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 34c4fe6c..6fd1a7e0 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -196,7 +196,7 @@ curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p' 完整演示脚本与录屏兜底见 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。 # 个人 AI 助理本地验证 -个人知识使用独立 Qdrant collection,默认 `aihr_personal_knowledge`,payload 强制包含 `tenant_id`、`owner_user_id`、`item_id` 与 `captured_at`。MySQL 表由 `backend/script/sql/aihr_personal_knowledge_mysql8.sql` 初始化;旧开发库先运行 `./scripts/reset-dev-db.sh`。 +个人知识使用独立 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.personal.*` 配置覆盖,包括空间字节配额、资料数量、单文件大小、抓取字节/超时、worker 与 cleanup 批量大小。不要把个人 collection 改回企业 `aihr_knowledge`。 @@ -207,4 +207,4 @@ curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p' ./scripts/personal-assistant-smoke.sh ``` -脚本用固定 smoke 手机号通过开发短信登录创建 A/B,验证 owner 隔离、READY、SSRF 和幂等删除,并在退出时清理 smoke 数据。脚本不会输出 token。 +脚本每次生成唯一 smoke 手机号与 run marker,通过开发短信登录创建 A/B,验证 owner 隔离、READY、SSRF 和幂等删除;退出时只按本次 user/item/session/OSS/job ID 清理。脚本不会输出 token。`./scripts/personal-assistant-smoke.sh --signal-self-test` 可单独验证 INT/TERM 分别返回 130/143。 diff --git a/scripts/personal-assistant-smoke.sh b/scripts/personal-assistant-smoke.sh index e5f03a96..c26c808e 100755 --- a/scripts/personal-assistant-smoke.sh +++ b/scripts/personal-assistant-smoke.sh @@ -1,27 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" API_URL="${AIHR_PERSONAL_API_URL:-https://wygj-api.localhost}" QDRANT_URL="${AIHR_QDRANT_URL:-http://127.0.0.1:6333}" QDRANT_COLLECTION="${AIHR_PERSONAL_QDRANT_COLLECTION:-aihr_personal_knowledge}" DB_CONTAINER="${AIHR_DB_CONTAINER:-wygj-mysql}" DB_NAME="${AIHR_DB_NAME:-ry-vue}" -REDIS_CONTAINER="${AIHR_REDIS_CONTAINER:-wygj-redis}" -DOCKER_NETWORK="${AIHR_DOCKER_NETWORK:-wygj_default}" -PHONE_A="13900009111" -PHONE_B="13900009222" SMS_CODE="${AIHR_SMS_DEV_CODE:-123456}" -TITLE="personal-assistant-smoke-isolation" -CONTENT="个人助理隔离烟测唯一标记,物业催费沟通资料。" +DOCKER_NETWORK="${AIHR_DOCKER_NETWORK:-wygj_default}" +RUN_ID="$(date +%Y%m%d%H%M%S)-$$-${RANDOM}" +PHONE_SUFFIX=$(( (10#$(date +%s) + $$ + RANDOM) % 99999998 + 1 )) +PHONE_A="138$(printf '%08d' "$PHONE_SUFFIX")" +PHONE_B="138$(printf '%08d' $((PHONE_SUFFIX % 99999999 + 1)))" +TITLE="personal-assistant-smoke-$RUN_ID" +CONTENT="个人助理隔离烟测唯一标记 ${RUN_ID},物业催费沟通资料。" -TOKEN_A="" -CLIENT_A="" -TOKEN_B="" -CLIENT_B="" -USER_A="" -USER_B="" -ITEM_ID="" +TOKEN_A="" CLIENT_A="" TOKEN_B="" CLIENT_B="" +USER_A="" USER_B="" ITEM_ID="" OSS_ID="" OBJECT_KEY="" JOB_ID="" SESSION_ID="" +CREATED_USER_A=0 CREATED_USER_B=0 CLEANED=0 +HTTP_STATUS="" HTTP_BODY="" LOGIN_TOKEN="" LOGIN_CLIENT="" fail() { echo "FAIL: $*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || fail "missing command: $1"; } @@ -29,142 +26,177 @@ expect_code() { local actual="$1" expected="$2" label="$3" [[ "$actual" == "$expected" ]] || fail "$label expected=$expected actual=$actual" } - mysql() { docker exec "$DB_CONTAINER" mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D "$DB_NAME" -e "$1" 2>/dev/null } -cleanup() { - local exit_code=$? - local oss_rows oss_id object_key +cleanup_once() { + [[ "$CLEANED" == 0 ]] || return 0 + CLEANED=1 + [[ "${AIHR_SMOKE_CLEANUP_DRY_RUN:-0}" != 1 ]] || return 0 set +e - [[ -n "$USER_A" ]] || USER_A="$(mysql "select user_id from sys_user where phonenumber='$PHONE_A' order by user_id desc limit 1" | head -1)" - [[ -n "$USER_B" ]] || USER_B="$(mysql "select user_id from sys_user where phonenumber='$PHONE_B' order by user_id desc limit 1" | head -1)" - for uid in "$USER_A" "$USER_B"; do - [[ "$uid" =~ ^[0-9]+$ ]] || continue - curl -fsS -X POST "$QDRANT_URL/collections/$QDRANT_COLLECTION/points/delete?wait=true" \ + if [[ "$USER_A" =~ ^[0-9]+$ && "$ITEM_ID" =~ ^[0-9]+$ ]]; then + curl -sS -X POST "$QDRANT_URL/collections/$QDRANT_COLLECTION/points/delete?wait=true" \ -H 'Content-Type: application/json' \ - --data "{\"filter\":{\"must\":[{\"key\":\"tenant_id\",\"match\":{\"value\":\"000000\"}},{\"key\":\"owner_user_id\",\"match\":{\"value\":$uid}}]}}" >/dev/null 2>&1 || true + --data "{\"filter\":{\"must\":[{\"key\":\"tenant_id\",\"match\":{\"value\":\"000000\"}},{\"key\":\"owner_user_id\",\"match\":{\"value\":$USER_A}},{\"key\":\"item_id\",\"match\":{\"value\":$ITEM_ID}}]}}" >/dev/null 2>&1 || true + fi + if [[ "$OSS_ID" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ && "$ITEM_ID" =~ ^[0-9]+$ \ + && "$OBJECT_KEY" =~ ^personal/000000/$USER_A/$ITEM_ID/[A-Za-z0-9._/-]+$ \ + && "$OBJECT_KEY" != *".."* ]]; then + docker run --rm --network "$DOCKER_NETWORK" \ + -e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc \ + rm --force "local/ruoyi-personal/$OBJECT_KEY" >/dev/null 2>&1 || true + mysql "delete from sys_oss where oss_id=$OSS_ID and service='personal-minio' and file_name='$OBJECT_KEY'" >/dev/null 2>&1 || true + fi + if [[ "$ITEM_ID" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ ]]; then + mysql "delete from aihr_personal_fragment where tenant_id='000000' and owner_user_id=$USER_A and item_id=$ITEM_ID; + delete from aihr_personal_cleanup_job where tenant_id='000000' and owner_user_id=$USER_A and item_id=$ITEM_ID; + delete from aihr_personal_item where tenant_id='000000' and owner_user_id=$USER_A and id=$ITEM_ID and title='$TITLE';" >/dev/null 2>&1 || true + fi + if [[ "$SESSION_ID" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ ]]; then + mysql "delete from aihr_personal_chat_message where tenant_id='000000' and owner_user_id=$USER_A and session_id=$SESSION_ID; + delete from aihr_personal_chat_session where tenant_id='000000' and owner_user_id=$USER_A and id=$SESSION_ID;" >/dev/null 2>&1 || true + fi + for owner in "$USER_A" "$USER_B"; do + [[ "$owner" =~ ^[0-9]+$ ]] || continue + mysql "delete from aihr_personal_space where tenant_id='000000' and owner_user_id=$owner and not exists + (select 1 from aihr_personal_item where tenant_id='000000' and owner_user_id=$owner);" >/dev/null 2>&1 || true done - oss_rows="$(mysql "select o.oss_id,o.file_name from sys_oss o join aihr_personal_item i on i.oss_id=o.oss_id where i.title='$TITLE' and i.owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0))" || true)" - while IFS=$'\t' read -r oss_id object_key; do - [[ "$oss_id" =~ ^[0-9]+$ && -n "$object_key" ]] || continue - docker run --rm --network "$DOCKER_NETWORK" --entrypoint /bin/sh minio/mc -c \ - "mc alias set local http://wygj-minio:9000 ruoyi ruoyi123 >/dev/null && mc rm --force 'local/ruoyi/$object_key' >/dev/null" \ - >/dev/null 2>&1 || true - mysql "delete from sys_oss where oss_id=$oss_id and file_name='$object_key'" >/dev/null 2>&1 || true - done <<<"$oss_rows" - mysql " - delete f from aihr_personal_fragment f join aihr_personal_item i on i.id=f.item_id - where i.title='$TITLE' and i.owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete j from aihr_personal_cleanup_job j join aihr_personal_item i on i.id=j.item_id - where i.title='$TITLE' and i.owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete from aihr_personal_item where title='$TITLE' - and owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete m from aihr_personal_chat_message m join aihr_personal_chat_session s on s.id=m.session_id - where s.owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete from aihr_personal_chat_session - where owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete from aihr_personal_space - where owner_user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete from sys_user_role where user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete from sys_user_post where user_id in (coalesce(nullif('$USER_A',''),0),coalesce(nullif('$USER_B',''),0)); - delete from sys_user where phonenumber in ('$PHONE_A','$PHONE_B') and remark='移动端短信自动注册'; - delete from sys_logininfor where user_name in ('$PHONE_A','$PHONE_B'); - " >/dev/null 2>&1 || true - docker exec "$REDIS_CONTAINER" redis-cli -a ruoyi123 DEL \ - "global:captcha_codes:$PHONE_A" "global:captcha_codes:$PHONE_B" >/dev/null 2>&1 || true + if [[ "$CREATED_USER_A" == 1 && "$USER_A" =~ ^[0-9]+$ ]]; then + mysql "delete from sys_user_role where user_id=$USER_A; delete from sys_user_post where user_id=$USER_A; + delete from sys_user where user_id=$USER_A and phonenumber='$PHONE_A' and remark='移动端短信自动注册'; + delete from sys_logininfor where user_name='$PHONE_A';" >/dev/null 2>&1 || true + fi + if [[ "$CREATED_USER_B" == 1 && "$USER_B" =~ ^[0-9]+$ ]]; then + mysql "delete from sys_user_role where user_id=$USER_B; delete from sys_user_post where user_id=$USER_B; + delete from sys_user where user_id=$USER_B and phonenumber='$PHONE_B' and remark='移动端短信自动注册'; + delete from sys_logininfor where user_name='$PHONE_B';" >/dev/null 2>&1 || true + fi for phone in "$PHONE_A" "$PHONE_B"; do - docker exec "$REDIS_CONTAINER" sh -c \ - "redis-cli -a ruoyi123 --scan --pattern '*resource/sms/code:$phone*' 2>/dev/null | xargs -r redis-cli -a ruoyi123 DEL" \ - >/dev/null 2>&1 || true + redis-cli -h 127.0.0.1 -p 16379 -a ruoyi123 DEL "global:captcha_codes:$phone" >/dev/null 2>&1 || true + while IFS= read -r key; do + [[ -n "$key" ]] && redis-cli -h 127.0.0.1 -p 16379 -a ruoyi123 DEL "$key" >/dev/null 2>&1 || true + done < <(redis-cli -h 127.0.0.1 -p 16379 -a ruoyi123 --scan --pattern "*resource/sms/code:$phone*" 2>/dev/null) done - return "$exit_code" } -trap cleanup EXIT INT TERM +on_exit() { local code=$?; trap - EXIT INT TERM; cleanup_once; exit "$code"; } +on_int() { trap - EXIT INT TERM; cleanup_once; exit 130; } +on_term() { trap - EXIT INT TERM; cleanup_once; exit 143; } +trap on_exit EXIT +trap on_int INT +trap on_term TERM -api() { - local token="$1" client="$2" method="$3" path="$4" body="${5:-}" - local args=(-kfsS -X "$method" "$API_URL$path" -H "Authorization: Bearer $token" -H "clientid: $client") +if [[ -n "${AIHR_SMOKE_SIGNAL_PROBE:-}" ]]; then + kill -s "$AIHR_SMOKE_SIGNAL_PROBE" "$$" + exit 99 +fi +if [[ "${1:-}" == "--signal-self-test" ]]; then + set +e + AIHR_SMOKE_CLEANUP_DRY_RUN=1 AIHR_SMOKE_SIGNAL_PROBE=INT "$0" >/dev/null 2>&1; int_code=$? + AIHR_SMOKE_CLEANUP_DRY_RUN=1 AIHR_SMOKE_SIGNAL_PROBE=TERM "$0" >/dev/null 2>&1; term_code=$? + set -e + expect_code "$int_code" 130 "INT exit status" + expect_code "$term_code" 143 "TERM exit status" + echo "PASS: signal exit statuses" + exit 0 +fi + +request() { + local method="$1" path="$2" token="${3:-}" client="${4:-}" body="${5:-}" tmp + tmp="$(mktemp)" + local args=(-k -sS -X "$method" "$API_URL$path" -o "$tmp" -w '%{http_code}') + [[ -z "$token" ]] || args+=(-H "Authorization: Bearer $token" -H "clientid: $client") [[ -z "$body" ]] || args+=(-H 'Content-Type: application/json' --data "$body") - curl "${args[@]}" + HTTP_STATUS="$(curl "${args[@]}" || printf '000')" + HTTP_BODY="$(cat "$tmp")" + rm -f "$tmp" +} +expect_success() { + local label="$1" + expect_code "$HTTP_STATUS" 200 "$label HTTP" + expect_code "$(jq -r '.code // empty' <<<"$HTTP_BODY")" 200 "$label business" +} +expect_error() { + local expected="$1" label="$2" + expect_code "$HTTP_STATUS" 200 "$label HTTP" + [[ "$(jq -r '.code // empty' <<<"$HTTP_BODY")" != 200 ]] || fail "$label unexpectedly succeeded" + expect_code "$(jq -r '.msg // empty' <<<"$HTTP_BODY")" "$expected" "$label message" } - login() { - local phone="$1" response - response="$(curl -kfsS "$API_URL/resource/sms/code?phonenumber=$phone")" - expect_code "$(jq -r '.code' <<<"$response")" "200" "send sms code" - response="$(curl -kfsS -X POST "$API_URL/auth/mobile/sms-login" -H 'Content-Type: application/json' \ - --data "{\"tenantId\":\"000000\",\"phonenumber\":\"$phone\",\"smsCode\":\"$SMS_CODE\"}")" - expect_code "$(jq -r '.code' <<<"$response")" "200" "mobile sms login" - jq -er '.data.access_token, .data.client_id' <<<"$response" | paste -sd ' ' - + local phone="$1" + request GET "/resource/sms/code?phonenumber=$phone" + expect_success "send sms code" + request POST /auth/mobile/sms-login "" "" \ + "{\"tenantId\":\"000000\",\"phonenumber\":\"$phone\",\"smsCode\":\"$SMS_CODE\"}" + expect_success "mobile sms login" + LOGIN_TOKEN="$(jq -er '.data.access_token' <<<"$HTTP_BODY")" + LOGIN_CLIENT="$(jq -er '.data.client_id' <<<"$HTTP_BODY")" } -need curl -need jq -need docker -curl -kfsS "$API_URL/auth/tenant/list" | jq -e '.code == 200' >/dev/null || fail "backend health is not UP: $API_URL" +need curl; need jq; need docker; need redis-cli +request GET /auth/tenant/list +expect_success "backend health" docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER" || fail "database container not running: $DB_CONTAINER" -docker ps --format '{{.Names}}' | grep -qx "$REDIS_CONTAINER" || fail "redis container not running: $REDIS_CONTAINER" -mysql "select 1 from aihr_personal_item limit 1" >/dev/null || fail "personal knowledge schema missing; run ./scripts/reset-dev-db.sh" +mysql "select 1 from aihr_personal_item limit 1" >/dev/null || fail "personal schema missing; run COMPOSE_PROJECT_NAME=wygj ./scripts/reset-dev-db.sh" +expect_code "$(mysql "select access_policy from sys_oss_config where tenant_id='000000' and config_key='minio'")" 1 "shared minio policy unchanged" +expect_code "$(mysql "select concat(bucket_name,':',access_policy) from sys_oss_config where tenant_id='000000' and config_key='personal-minio'")" "ruoyi-personal:0" "personal private storage config" +expect_code "$(mysql "select count(*) from sys_user where phonenumber in ('$PHONE_A','$PHONE_B')")" 0 "unique smoke users must not preexist" -cleanup -read -r TOKEN_A CLIENT_A <<<"$(login "$PHONE_A")" -read -r TOKEN_B CLIENT_B <<<"$(login "$PHONE_B")" -USER_A="$(mysql "select user_id from sys_user where phonenumber='$PHONE_A' order by user_id desc limit 1")" -USER_B="$(mysql "select user_id from sys_user where phonenumber='$PHONE_B' order by user_id desc limit 1")" -[[ "$USER_A" =~ ^[0-9]+$ && "$USER_B" =~ ^[0-9]+$ && "$USER_A" != "$USER_B" ]] || fail "smoke users were not created independently" +login "$PHONE_A"; TOKEN_A="$LOGIN_TOKEN"; CLIENT_A="$LOGIN_CLIENT" +USER_A="$(mysql "select user_id from sys_user where phonenumber='$PHONE_A' and remark='移动端短信自动注册'")" +[[ "$USER_A" =~ ^[0-9]+$ ]] || fail "A smoke user was not created"; CREATED_USER_A=1 +login "$PHONE_B"; TOKEN_B="$LOGIN_TOKEN"; CLIENT_B="$LOGIN_CLIENT" +USER_B="$(mysql "select user_id from sys_user where phonenumber='$PHONE_B' and remark='移动端短信自动注册'")" +[[ "$USER_B" =~ ^[0-9]+$ && "$USER_A" != "$USER_B" ]] || fail "B smoke user was not created independently"; CREATED_USER_B=1 -created="$(api "$TOKEN_A" "$CLIENT_A" POST /api/aihr/personal-assistant/items/text \ - "{\"title\":\"$TITLE\",\"content\":\"$CONTENT\",\"tags\":[\"smoke\"]}")" -expect_code "$(jq -r '.code' <<<"$created")" "200" "A create text item" -ITEM_ID="$(jq -er '.data.itemId' <<<"$created")" +request POST /api/aihr/personal-assistant/items/text "$TOKEN_A" "$CLIENT_A" \ + "{\"title\":\"$TITLE\",\"content\":\"$CONTENT\",\"tags\":[\"smoke\",\"$RUN_ID\"]}" +expect_success "A create text item" +ITEM_ID="$(jq -er '.data.itemId | tostring' <<<"$HTTP_BODY")" +read -r OSS_ID OBJECT_KEY <<<"$(mysql "select o.oss_id,o.file_name from sys_oss o join aihr_personal_item i on i.oss_id=o.oss_id where i.id=$ITEM_ID and i.owner_user_id=$USER_A and i.title='$TITLE'")" +[[ "$OSS_ID" =~ ^[0-9]+$ && "$OBJECT_KEY" =~ ^personal/000000/$USER_A/$ITEM_ID/[A-Za-z0-9._/-]+$ ]] || fail "unexpected personal OSS ownership metadata" for _ in {1..40}; do - detail="$(api "$TOKEN_A" "$CLIENT_A" GET "/api/aihr/personal-assistant/items/$ITEM_ID")" - [[ "$(jq -r '.data.status // empty' <<<"$detail")" == "READY" ]] && break + request GET "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A" + [[ "$(jq -r '.data.status // empty' <<<"$HTTP_BODY")" == READY ]] && break sleep 1 done -expect_code "$(jq -r '.data.status // empty' <<<"$detail")" "READY" "A text item processing" -expect_code "$(jq -r '.data.title // empty' <<<"$detail")" "$TITLE" "A item detail" - -search_a="$(api "$TOKEN_A" "$CLIENT_A" POST /api/aihr/personal-assistant/search \ - "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[$ITEM_ID],\"limit\":10}")" -expect_code "$(jq -r '.code' <<<"$search_a")" "200" "A search" -[[ "$(jq --arg item "$ITEM_ID" '[.data.hits[] | select((.itemId | tostring) == $item)] | length' <<<"$search_a")" -ge 1 ]] || { - jq '{code,msg,data}' <<<"$search_a" >&2 - fail "A search did not return own item" -} +expect_success "A item detail" +expect_code "$(jq -r '.data.status' <<<"$HTTP_BODY")" READY "A text item processing" +request POST /api/aihr/personal-assistant/search "$TOKEN_A" "$CLIENT_A" \ + "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[\"$ITEM_ID\"],\"limit\":10}" +expect_success "A itemIds search" +[[ "$(jq --arg item "$ITEM_ID" '[.data.hits[] | select((.itemId|tostring)==$item)]|length' <<<"$HTTP_BODY")" -ge 1 ]] || fail "A search did not return own item" +request POST /api/aihr/personal-assistant/ask "$TOKEN_A" "$CLIENT_A" \ + "{\"queryText\":\"隔离烟测资料是什么\",\"scope\":[\"PERSONAL\"],\"itemIds\":[\"$ITEM_ID\"]}" +expect_success "A personal answer" +SESSION_ID="$(jq -er '.data.sessionId | tostring' <<<"$HTTP_BODY")" +[[ "$SESSION_ID" =~ ^[0-9]+$ ]] || fail "A answer did not persist session" +request GET "/api/aihr/personal-assistant/sessions/$SESSION_ID" "$TOKEN_B" "$CLIENT_B" +expect_error PERSONAL_SESSION_NOT_FOUND "B session isolation" for operation in detail download retry delete; do case "$operation" in - detail) response="$(api "$TOKEN_B" "$CLIENT_B" GET "/api/aihr/personal-assistant/items/$ITEM_ID" || true)" ;; - download) response="$(api "$TOKEN_B" "$CLIENT_B" GET "/api/aihr/personal-assistant/items/$ITEM_ID/download-url" || true)" ;; - retry) response="$(api "$TOKEN_B" "$CLIENT_B" POST "/api/aihr/personal-assistant/items/$ITEM_ID/retry" || true)" ;; - delete) response="$(api "$TOKEN_B" "$CLIENT_B" DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" || true)" ;; + detail) request GET "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_B" "$CLIENT_B" ;; + download) request GET "/api/aihr/personal-assistant/items/$ITEM_ID/download-url" "$TOKEN_B" "$CLIENT_B" ;; + retry) request POST "/api/aihr/personal-assistant/items/$ITEM_ID/retry" "$TOKEN_B" "$CLIENT_B" ;; + delete) request DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_B" "$CLIENT_B" ;; esac - expect_code "$(jq -r '.msg // empty' <<<"$response")" "PERSONAL_ITEM_NOT_FOUND" "B $operation A item" + expect_error PERSONAL_ITEM_NOT_FOUND "B $operation A item" done - -search_b="$(api "$TOKEN_B" "$CLIENT_B" POST /api/aihr/personal-assistant/search \ - "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[$ITEM_ID],\"limit\":10}")" -expect_code "$(jq -r '.code' <<<"$search_b")" "200" "B itemIds search" -expect_code "$(jq '.data.hits | length' <<<"$search_b")" "0" "B itemIds search isolation" +request POST /api/aihr/personal-assistant/search "$TOKEN_B" "$CLIENT_B" \ + "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[\"$ITEM_ID\"],\"limit\":10}" +expect_error PERSONAL_ITEM_NOT_FOUND "B itemIds search isolation" for blocked_url in 'http://127.0.0.1/admin' 'http://169.254.169.254/latest/meta-data/'; do - response="$(api "$TOKEN_A" "$CLIENT_A" POST /api/aihr/personal-assistant/items/url \ - "{\"url\":\"$blocked_url\",\"title\":\"$TITLE\"}" || true)" - expect_code "$(jq -r '.msg // empty' <<<"$response")" "PERSONAL_URL_BLOCKED" "SSRF $blocked_url" + request POST /api/aihr/personal-assistant/items/url "$TOKEN_A" "$CLIENT_A" \ + "{\"url\":\"$blocked_url\",\"title\":\"$TITLE\"}" + expect_error PERSONAL_URL_BLOCKED "SSRF $blocked_url" done -deleted1="$(api "$TOKEN_A" "$CLIENT_A" DELETE "/api/aihr/personal-assistant/items/$ITEM_ID")" -deleted2="$(api "$TOKEN_A" "$CLIENT_A" DELETE "/api/aihr/personal-assistant/items/$ITEM_ID")" -expect_code "$(jq -r '.code' <<<"$deleted1")" "200" "A delete" -expect_code "$(jq -r '.data.cleanupJobId' <<<"$deleted2")" "$(jq -r '.data.cleanupJobId' <<<"$deleted1")" "idempotent delete job" -response="$(api "$TOKEN_A" "$CLIENT_A" GET "/api/aihr/personal-assistant/items/$ITEM_ID" || true)" -expect_code "$(jq -r '.msg // empty' <<<"$response")" "PERSONAL_ITEM_NOT_FOUND" "A item hidden immediately" -response="$(api "$TOKEN_A" "$CLIENT_A" POST /api/aihr/personal-assistant/search \ - "{\"queryText\":\"隔离烟测\",\"scope\":[\"PERSONAL\"],\"itemIds\":[$ITEM_ID],\"limit\":10}")" -expect_code "$(jq '.data.hits | length' <<<"$response")" "0" "deleted item search" - -echo "PASS: personal assistant isolation, SSRF, readiness and deletion gates" +request DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A"; expect_success "A delete" +JOB_ID="$(jq -er '.data.cleanupJobId | tostring' <<<"$HTTP_BODY")" +request DELETE "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A"; expect_success "A repeat delete" +expect_code "$(jq -r '.data.cleanupJobId | tostring' <<<"$HTTP_BODY")" "$JOB_ID" "idempotent delete job" +request GET "/api/aihr/personal-assistant/items/$ITEM_ID" "$TOKEN_A" "$CLIENT_A"; expect_error PERSONAL_ITEM_NOT_FOUND "A item hidden immediately" +echo "PASS: personal assistant isolation, SSRF, readiness and deletion gates run=$RUN_ID"