fix(personal): harden pilot smoke and private storage
This commit is contained in:
+20
-1
@@ -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<SearchHitResponse> fulltext;
|
||||
try {
|
||||
@@ -235,6 +237,23 @@ public class PersonalRetrievalService {
|
||||
args.addAll(itemIds);
|
||||
}
|
||||
|
||||
private void requireOwnedReadyItems(PersonalOwner owner, List<Long> 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<Object> 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");
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+46
@@ -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);
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
@@ -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 '主键',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`,不以空结果掩盖越权。
|
||||
|
||||
+2
-2
@@ -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。
|
||||
|
||||
+153
-121
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user