fix(personal): harden pilot smoke and private storage

This commit is contained in:
2026-07-12 12:57:29 +08:00
parent 60a78fdd6a
commit 784384bf26
10 changed files with 249 additions and 132 deletions
@@ -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");
@@ -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;
@@ -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
@@ -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);
@@ -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 '主键',
+2 -1
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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"