Merge branch 'codex/personal-assistant-phase2' into codex/multi-tenant-knowledge-platform
# Conflicts: # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java # backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java # backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java # docs/API_INTEGRATION.md # docs/个人AI助理阶段二专项TechSpec.md # docs/个人AI助理阶段二开发推进计划.md # frontend/src/views/knowledge/processing.vue # mobile-uni/src/pages/user/sop/index.vue # mobile-uni/src/services/api.ts # mobile-uni/src/services/personal-assistant.ts # mobile-uni/tests/personal-assistant.test.mjs # scripts/demo-check.sh # scripts/personal-assistant-smoke.sh
This commit is contained in:
@@ -1,62 +1,523 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE="${API_BASE:-https://wygj-api.localhost}"
|
||||
TOKEN="${TOKEN:-}"
|
||||
CLIENT_ID="${CLIENT_ID:-428a8310cd442757ae699df5d894f051}"
|
||||
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}"
|
||||
SMS_CODE="${AIHR_SMS_DEV_CODE:-123456}"
|
||||
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"
|
||||
TEXT_QUERY="Personal text isolation evidence"
|
||||
CONTENT="个人助理隔离烟测唯一标记 ${RUN_ID},物业催费沟通资料。${TEXT_QUERY} for run ${RUN_ID}."
|
||||
TEXT_TITLE="$TITLE-text"
|
||||
PDF_TITLE="$TITLE-pdf"
|
||||
URL_TITLE="$TITLE-url"
|
||||
PDF_QUERY="Personal PDF verification evidence"
|
||||
PUBLIC_URL="${AIHR_PERSONAL_SMOKE_PUBLIC_URL:-https://example.com/}"
|
||||
PUBLIC_EXPECTED_URL="${AIHR_PERSONAL_SMOKE_EXPECTED_PUBLIC_URL:-}"
|
||||
[[ -n "${AIHR_PERSONAL_SMOKE_PUBLIC_URL:-}" ]] || PUBLIC_EXPECTED_URL="https://example.com/"
|
||||
PUBLIC_QUERY="${AIHR_PERSONAL_SMOKE_PUBLIC_QUERY:-Example Domain}"
|
||||
SCANNED_PDF="${AIHR_PERSONAL_SCANNED_PDF:-}"
|
||||
SCANNED_QUERY="${AIHR_PERSONAL_SCANNED_QUERY:-证书管理办法}"
|
||||
SCANNED_TITLE="$TITLE-scanned-pdf"
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/wygj-personal-smoke.XXXXXX")"
|
||||
chmod 700 "$TMP_ROOT"
|
||||
|
||||
fail() {
|
||||
echo "personal-assistant-smoke: $*" >&2
|
||||
exit 1
|
||||
TOKEN_A="" CLIENT_A="" TOKEN_B="" CLIENT_B=""
|
||||
USER_A="" USER_B="" SESSION_ID=""
|
||||
SCANNED_ITEM_ID=""
|
||||
OWN_PHONE_A=0 OWN_PHONE_B=0 CLEANED=0
|
||||
HTTP_STATUS="" HTTP_BODY="" LOGIN_TOKEN="" LOGIN_CLIENT=""
|
||||
TEMP_FILES=()
|
||||
ITEM_IDS=()
|
||||
OSS_IDS=()
|
||||
OBJECT_KEYS=()
|
||||
OSS_URLS=()
|
||||
JOB_IDS=()
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || fail "missing command: $1"; }
|
||||
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
|
||||
}
|
||||
register_tmp() {
|
||||
local path="$1"
|
||||
chmod 600 "$path"
|
||||
TEMP_FILES+=("$path")
|
||||
}
|
||||
|
||||
append_item_metadata() {
|
||||
local item_id="$1" item_title="$2" oss_id object_key oss_url
|
||||
read -r oss_id object_key oss_url <<<"$(mysql "select o.oss_id,o.file_name,o.url 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='$item_title' limit 1")"
|
||||
[[ "$oss_id" =~ ^[0-9]+$ && "$object_key" =~ ^personal/000000/$USER_A/$item_id/[A-Za-z0-9._/-]+$ \
|
||||
&& "$object_key" != *".."* && -n "$oss_url" ]] || fail "unexpected personal OSS ownership metadata item=$item_id"
|
||||
ITEM_IDS+=("$item_id")
|
||||
OSS_IDS+=("$oss_id")
|
||||
OBJECT_KEYS+=("$object_key")
|
||||
OSS_URLS+=("$oss_url")
|
||||
}
|
||||
|
||||
discover_run_items() {
|
||||
[[ "$USER_A" =~ ^[0-9]+$ ]] || return 0
|
||||
while IFS=$'\t' read -r item_id oss_id object_key oss_url; do
|
||||
[[ "$item_id" =~ ^[0-9]+$ ]] || continue
|
||||
local seen=0 existing
|
||||
for existing in "${ITEM_IDS[@]}"; do
|
||||
[[ "$existing" == "$item_id" ]] && seen=1 && break
|
||||
done
|
||||
[[ "$seen" == 0 ]] || continue
|
||||
ITEM_IDS+=("$item_id")
|
||||
OSS_IDS+=("${oss_id:-}")
|
||||
OBJECT_KEYS+=("${object_key:-}")
|
||||
OSS_URLS+=("${oss_url:-}")
|
||||
done < <(mysql "select i.id,coalesce(o.oss_id,''),coalesce(o.file_name,''),coalesce(o.url,'')
|
||||
from aihr_personal_item i left join sys_oss o on o.oss_id=i.oss_id
|
||||
where i.tenant_id='000000' and i.owner_user_id=$USER_A and i.title like '$TITLE-%'
|
||||
order by i.id")
|
||||
}
|
||||
|
||||
cleanup_once() {
|
||||
[[ "$CLEANED" == 0 ]] || return 0
|
||||
CLEANED=1
|
||||
set +e
|
||||
[[ ${#TEMP_FILES[@]} == 0 ]] || rm -f -- "${TEMP_FILES[@]}"
|
||||
rm -rf -- "$TMP_ROOT"
|
||||
[[ "${AIHR_SMOKE_CLEANUP_DRY_RUN:-0}" != 1 ]] || return 0
|
||||
if [[ "$OWN_PHONE_A" == 1 && ! "$USER_A" =~ ^[0-9]+$ ]]; then
|
||||
USER_A="$(mysql "select user_id from sys_user where phonenumber='$PHONE_A' and remark='移动端短信自动注册' order by user_id desc limit 1" | head -1)"
|
||||
fi
|
||||
if [[ "$OWN_PHONE_B" == 1 && ! "$USER_B" =~ ^[0-9]+$ ]]; then
|
||||
USER_B="$(mysql "select user_id from sys_user where phonenumber='$PHONE_B' and remark='移动端短信自动注册' order by user_id desc limit 1" | head -1)"
|
||||
fi
|
||||
discover_run_items
|
||||
local index item_id oss_id object_key
|
||||
for index in "${!ITEM_IDS[@]}"; do
|
||||
item_id="${ITEM_IDS[$index]}"
|
||||
oss_id="${OSS_IDS[$index]:-}"
|
||||
object_key="${OBJECT_KEYS[$index]:-}"
|
||||
[[ "$item_id" =~ ^[0-9]+$ && "$USER_A" =~ ^[0-9]+$ ]] || continue
|
||||
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\":$USER_A}},{\"key\":\"item_id\",\"match\":{\"value\":$item_id}}]}}" >/dev/null 2>&1 || true
|
||||
if [[ "$oss_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
|
||||
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_ocr_page where tenant_id='000000' and owner_user_id=$USER_A and item_id=$item_id;
|
||||
delete from aihr_personal_ocr_job 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 like '$TITLE-%';" >/dev/null 2>&1 || true
|
||||
done
|
||||
for owner in "$USER_A" "$USER_B"; do
|
||||
[[ "$owner" =~ ^[0-9]+$ ]] || continue
|
||||
mysql "delete from aihr_personal_chat_message where tenant_id='000000' and owner_user_id=$owner;
|
||||
delete from aihr_personal_chat_session where tenant_id='000000' and owner_user_id=$owner;" >/dev/null 2>&1 || true
|
||||
done
|
||||
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
|
||||
if [[ "$OWN_PHONE_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 [[ "$OWN_PHONE_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
|
||||
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
|
||||
}
|
||||
|
||||
assert_identity_cleanup() {
|
||||
local owner session_count message_count
|
||||
for owner in "$USER_A" "$USER_B"; do
|
||||
[[ "$owner" =~ ^[0-9]+$ ]] || continue
|
||||
session_count="$(mysql "select count(*) from aihr_personal_chat_session where tenant_id='000000' and owner_user_id=$owner")"
|
||||
message_count="$(mysql "select count(*) from aihr_personal_chat_message where tenant_id='000000' and owner_user_id=$owner")"
|
||||
[[ "$session_count" == 0 && "$message_count" == 0 ]] || {
|
||||
echo "FAIL: personal chat residue owner=$owner sessions=$session_count messages=$message_count" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
[[ "$(mysql "select count(*) from sys_user where phonenumber in ('$PHONE_A','$PHONE_B')")" == 0 ]] || {
|
||||
echo "FAIL: smoke users were not removed" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
local code=$?
|
||||
trap - EXIT INT TERM
|
||||
cleanup_once
|
||||
if [[ "$code" != 0 && "${AIHR_SMOKE_CLEANUP_DRY_RUN:-0}" != 1 ]]; then
|
||||
if assert_identity_cleanup; then
|
||||
echo "PASS: failure-window owner session and user cleanup"
|
||||
else
|
||||
code=1
|
||||
fi
|
||||
fi
|
||||
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
|
||||
|
||||
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 path="$1"
|
||||
local body_file
|
||||
local status
|
||||
body_file="$(mktemp)"
|
||||
if [[ -n "$TOKEN" ]]; then
|
||||
status="$(curl -ksS --max-time 15 -o "$body_file" -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "clientid: $CLIENT_ID" \
|
||||
"$API_BASE$path")" || {
|
||||
rm -f "$body_file"
|
||||
fail "request failed: $path"
|
||||
}
|
||||
else
|
||||
status="$(curl -ksS --max-time 15 -o "$body_file" -w '%{http_code}' "$API_BASE$path")" || {
|
||||
rm -f "$body_file"
|
||||
fail "request failed: $path"
|
||||
}
|
||||
fi
|
||||
RESPONSE_BODY="$(tr -d '\n' < "$body_file")"
|
||||
RESPONSE_STATUS="$status"
|
||||
rm -f "$body_file"
|
||||
local method="$1" path="$2" token="${3:-}" client="${4:-}" body="${5:-}" tmp
|
||||
tmp="$(mktemp "$TMP_ROOT/http.XXXXXX")"
|
||||
register_tmp "$tmp"
|
||||
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")
|
||||
HTTP_STATUS="$(curl "${args[@]}" || printf '000')"
|
||||
HTTP_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
assert_protected_or_success() {
|
||||
request_file() {
|
||||
local path="$1" token="$2" client="$3" file="$4" title="$5" tmp
|
||||
tmp="$(mktemp "$TMP_ROOT/http-file.XXXXXX")"
|
||||
register_tmp "$tmp"
|
||||
HTTP_STATUS="$(curl -k -sS -X POST "$API_URL$path" -o "$tmp" -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $token" -H "clientid: $client" \
|
||||
-F "file=@$file;type=application/pdf;filename=$(basename "$file")" \
|
||||
-F "title=$title" || printf '000')"
|
||||
HTTP_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
expect_success() {
|
||||
local label="$1"
|
||||
local path="$2"
|
||||
request "$path"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
if [[ "$RESPONSE_STATUS" =~ ^(401|403)$ ]] || printf '%s' "$RESPONSE_BODY" | grep -Eq '"code"[[:space:]]*:[[:space:]]*(401|403)'; then
|
||||
echo "$label=protected"
|
||||
return
|
||||
fi
|
||||
fail "$label allowed anonymous access (HTTP $RESPONSE_STATUS): $RESPONSE_BODY"
|
||||
fi
|
||||
|
||||
[[ "$RESPONSE_STATUS" == "200" ]] || fail "$label HTTP $RESPONSE_STATUS: $RESPONSE_BODY"
|
||||
printf '%s' "$RESPONSE_BODY" | grep -Eq '"code"[[:space:]]*:[[:space:]]*200([[:space:]]*[,}])' \
|
||||
|| fail "$label business response failed: $RESPONSE_BODY"
|
||||
echo "$label=200"
|
||||
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"
|
||||
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")"
|
||||
}
|
||||
|
||||
assert_protected_or_success "memory_candidates" "/api/aihr/personal-assistant/memory-candidates?status=DRAFT"
|
||||
assert_protected_or_success "service_memories" "/api/aihr/service-memories?limit=5"
|
||||
wait_ready() {
|
||||
local item_id="$1" label="$2" status=""
|
||||
for _ in {1..60}; do
|
||||
request GET "/api/aihr/personal-assistant/items/$item_id" "$TOKEN_A" "$CLIENT_A"
|
||||
expect_success "$label detail poll"
|
||||
status="$(jq -r '.data.status // empty' <<<"$HTTP_BODY")"
|
||||
[[ "$status" == READY ]] && return 0
|
||||
[[ "$status" != FAILED ]] || fail "$label processing failed code=$(jq -r '.data.errorCode // empty' <<<"$HTTP_BODY")"
|
||||
sleep 1
|
||||
done
|
||||
fail "$label did not become READY lastStatus=$status"
|
||||
}
|
||||
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "personal-assistant-smoke: authentication boundary passed; set TOKEN to add read-only authenticated checks"
|
||||
else
|
||||
echo "personal-assistant-smoke: authenticated read-only checks passed"
|
||||
wait_scanned_ready() {
|
||||
local item_id="$1" status="" ocr_status=""
|
||||
for _ in {1..180}; do
|
||||
request GET "/api/aihr/personal-assistant/items/$item_id" "$TOKEN_A" "$CLIENT_A"
|
||||
expect_success "scanned PDF detail poll"
|
||||
status="$(jq -r '.data.status // empty' <<<"$HTTP_BODY")"
|
||||
ocr_status="$(jq -r '.data.ocr.status // empty' <<<"$HTTP_BODY")"
|
||||
[[ "$status" == READY ]] && return 0
|
||||
[[ "$status" != FAILED ]] \
|
||||
|| fail "scanned PDF OCR failed code=$(jq -r '.data.errorCode // empty' <<<"$HTTP_BODY") ocrStatus=$ocr_status failedPages=$(jq -c '.data.ocr.failedPageNumbers // []' <<<"$HTTP_BODY")"
|
||||
sleep 1
|
||||
done
|
||||
fail "scanned PDF did not become READY lastStatus=$status ocrStatus=$ocr_status"
|
||||
}
|
||||
|
||||
assert_anonymous_private() {
|
||||
local url="$1" label="$2" tmp status
|
||||
tmp="$(mktemp "$TMP_ROOT/anonymous.XXXXXX")"
|
||||
register_tmp "$tmp"
|
||||
status="$(curl -sS -o "$tmp" -w '%{http_code}' "$url" || printf '000')"
|
||||
expect_code "$status" 403 "$label anonymous GET"
|
||||
}
|
||||
|
||||
assert_search_hit() {
|
||||
local item_id="$1" query="$2" expected_type="$3" label="$4" item_ids_json
|
||||
item_ids_json="$(jq -cn --argjson item "$item_id" '[$item]')"
|
||||
request POST /api/aihr/personal-assistant/search "$TOKEN_A" "$CLIENT_A" \
|
||||
"$(jq -cn --arg query "$query" --argjson ids "$item_ids_json" \
|
||||
'{queryText:$query,scope:["PERSONAL"],itemIds:$ids,limit:10}')"
|
||||
expect_success "$label itemIds search"
|
||||
[[ "$(jq --arg item "$item_id" --arg type "$expected_type" \
|
||||
'[.data.hits[] | select((.itemId|tostring)==$item and .sourceType==$type)]|length' <<<"$HTTP_BODY")" -ge 1 ]] \
|
||||
|| fail "$label search did not return item=$item_id type=$expected_type query=$query hits=$(jq -c '.data.hits // []' <<<"$HTTP_BODY")"
|
||||
}
|
||||
|
||||
assert_single_item_ask() {
|
||||
local item_id="$1" query="$2" expected_type="$3" label="$4" session_json=null response_session
|
||||
[[ "$SESSION_ID" =~ ^[0-9]+$ ]] && session_json="$SESSION_ID"
|
||||
request POST /api/aihr/personal-assistant/ask "$TOKEN_A" "$CLIENT_A" \
|
||||
"$(jq -cn --arg query "$query" --argjson item "$item_id" --argjson session "$session_json" \
|
||||
'{sessionId:$session,queryText:$query,scope:["PERSONAL"],itemIds:[$item]}')"
|
||||
expect_success "A $label single-item answer"
|
||||
response_session="$(jq -er '.data.sessionId | tostring' <<<"$HTTP_BODY")"
|
||||
[[ "$response_session" =~ ^[0-9]+$ ]] || fail "A $label answer did not persist session"
|
||||
[[ "${AIHR_SMOKE_FAIL_AFTER_SESSION_CREATED:-0}" != 1 ]] \
|
||||
|| fail "injected failure after backend session creation"
|
||||
if [[ "$SESSION_ID" =~ ^[0-9]+$ ]]; then
|
||||
expect_code "$response_session" "$SESSION_ID" "A $label answer session continuity"
|
||||
else
|
||||
SESSION_ID="$response_session"
|
||||
fi
|
||||
[[ "$(jq --arg item "$item_id" --arg type "$expected_type" \
|
||||
'[.data.citations[] | select(.domain=="PERSONAL" and (.itemId|tostring)==$item and .sourceType==$type)]|length' \
|
||||
<<<"$HTTP_BODY")" -ge 1 ]] \
|
||||
|| fail "A $label answer lacks exact PERSONAL/$expected_type citation item=$item_id citations=$(jq -c '.data.citations // []' <<<"$HTTP_BODY")"
|
||||
expect_code "$(jq --arg item "$item_id" --arg type "$expected_type" \
|
||||
'[.data.citations[] | select(.domain!="PERSONAL" or (.itemId|tostring)!=$item or .sourceType!=$type)]|length' \
|
||||
<<<"$HTTP_BODY")" 0 "A $label answer foreign citation count"
|
||||
}
|
||||
|
||||
qdrant_item_count() {
|
||||
local item_id="$1" collection_exists count=0
|
||||
collection_exists="$(curl -sS "$QDRANT_URL/collections" \
|
||||
| jq -er --arg name "$QDRANT_COLLECTION" '[.result.collections[] | select(.name==$name)]|length')" \
|
||||
|| fail "Qdrant collection discovery failed"
|
||||
if [[ "$collection_exists" -ge 1 ]]; then
|
||||
count="$(curl -sS -X POST "$QDRANT_URL/collections/$QDRANT_COLLECTION/points/count" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "{\"exact\":true,\"filter\":{\"must\":[{\"key\":\"tenant_id\",\"match\":{\"value\":\"000000\"}},{\"key\":\"owner_user_id\",\"match\":{\"value\":$USER_A}},{\"key\":\"item_id\",\"match\":{\"value\":$item_id}}]}}" \
|
||||
| jq -er '.result.count')" || fail "Qdrant residual check failed item=$item_id"
|
||||
fi
|
||||
printf '%s' "$count"
|
||||
}
|
||||
|
||||
assert_business_cleanup() {
|
||||
local ids="$TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID" index
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_cleanup_job
|
||||
where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($ids) and status='DONE'")" \
|
||||
3 "application cleanup jobs DONE"
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_item
|
||||
where tenant_id='000000' and owner_user_id=$USER_A and id in ($ids) and status='DELETED'")" \
|
||||
3 "application item tombstones DELETED"
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_fragment
|
||||
where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($ids)")" \
|
||||
0 "application fragment residual"
|
||||
for index in "${!OSS_IDS[@]}"; do
|
||||
expect_code "$(mysql "select count(*) from sys_oss where oss_id=${OSS_IDS[$index]}")" \
|
||||
0 "application OSS row ${OSS_IDS[$index]} residual"
|
||||
if docker run --rm --network "$DOCKER_NETWORK" \
|
||||
-e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc \
|
||||
stat "local/ruoyi-personal/${OBJECT_KEYS[$index]}" >/dev/null 2>&1; then
|
||||
fail "application MinIO object ${OBJECT_KEYS[$index]} residual"
|
||||
fi
|
||||
expect_code "$(qdrant_item_count "${ITEM_IDS[$index]}")" 0 \
|
||||
"application Qdrant points item=${ITEM_IDS[$index]} residual"
|
||||
done
|
||||
}
|
||||
|
||||
assert_b_isolation() {
|
||||
local item_id="$1" label="$2" operation
|
||||
for operation in detail download retry delete; do
|
||||
case "$operation" in
|
||||
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_error PERSONAL_ITEM_NOT_FOUND "B $operation A $label item"
|
||||
done
|
||||
request POST /api/aihr/personal-assistant/search "$TOKEN_B" "$CLIENT_B" \
|
||||
"$(jq -cn --argjson item "$item_id" '{queryText:"smoke",scope:["PERSONAL"],itemIds:[$item],limit:10}')"
|
||||
expect_error PERSONAL_ITEM_NOT_FOUND "B itemIds search A $label isolation"
|
||||
}
|
||||
|
||||
need curl; need jq; need docker; need redis-cli; need cupsfilter
|
||||
case "$PUBLIC_URL" in
|
||||
http://localhost*|https://localhost*|http://127.*|https://127.*|http://\[*|https://\[*|http://169.254.*|https://169.254.*)
|
||||
fail "AIHR_PERSONAL_SMOKE_PUBLIC_URL must be a public URL, not localhost/private metadata"
|
||||
;;
|
||||
http://*|https://*)
|
||||
[[ "$PUBLIC_URL" =~ ^https?://[^/?#]+([/?#].*)?$ ]] \
|
||||
|| fail "AIHR_PERSONAL_SMOKE_PUBLIC_URL has no valid HTTP(S) authority"
|
||||
;;
|
||||
*) fail "AIHR_PERSONAL_SMOKE_PUBLIC_URL must use http or https" ;;
|
||||
esac
|
||||
request GET /auth/tenant/list
|
||||
expect_success "backend health"
|
||||
docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER" || fail "database container not running: $DB_CONTAINER"
|
||||
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"
|
||||
mysql "select 1 from aihr_personal_ocr_job limit 1" >/dev/null || fail "personal OCR job schema missing; import aihr_personal_knowledge_mysql8.sql"
|
||||
mysql "select 1 from aihr_personal_ocr_page limit 1" >/dev/null || fail "personal OCR page schema missing; import aihr_personal_knowledge_mysql8.sql"
|
||||
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"
|
||||
OWN_PHONE_A=1
|
||||
OWN_PHONE_B=1
|
||||
bucket_policy="$(docker run --rm --network "$DOCKER_NETWORK" \
|
||||
-e 'MC_HOST_local=http://ruoyi:ruoyi123@wygj-minio:9000' minio/mc anonymous get local/ruoyi-personal 2>/dev/null)"
|
||||
[[ "$bucket_policy" == *"private"* ]] || fail "personal bucket anonymous policy is not private"
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
request POST /api/aihr/personal-assistant/items/text "$TOKEN_A" "$CLIENT_A" \
|
||||
"$(jq -cn --arg title "$TEXT_TITLE" --arg content "$CONTENT" --arg run "$RUN_ID" \
|
||||
'{title:$title,content:$content,tags:["smoke",$run]}')"
|
||||
expect_success "A create text item"
|
||||
TEXT_ITEM_ID="$(jq -er '.data.itemId | tostring' <<<"$HTTP_BODY")"
|
||||
append_item_metadata "$TEXT_ITEM_ID" "$TEXT_TITLE"
|
||||
|
||||
PDF_TEXT="$TMP_ROOT/personal-smoke-$RUN_ID.txt"
|
||||
PDF_FILE="$TMP_ROOT/personal-smoke-$RUN_ID.pdf"
|
||||
printf 'Personal PDF verification evidence for run %s. Property service fee communication checklist.\n' \
|
||||
"$RUN_ID" >"$PDF_TEXT"
|
||||
cupsfilter -m application/pdf "$PDF_TEXT" >"$PDF_FILE" 2>"$TMP_ROOT/cupsfilter.log" \
|
||||
|| fail "cupsfilter failed to generate smoke PDF"
|
||||
[[ -s "$PDF_FILE" && "$(head -c 4 "$PDF_FILE")" == '%PDF' ]] || fail "cupsfilter output is not a PDF"
|
||||
request_file /api/aihr/personal-assistant/items/file "$TOKEN_A" "$CLIENT_A" "$PDF_FILE" "$PDF_TITLE"
|
||||
expect_success "A create PDF item"
|
||||
PDF_ITEM_ID="$(jq -er '.data.itemId | tostring' <<<"$HTTP_BODY")"
|
||||
append_item_metadata "$PDF_ITEM_ID" "$PDF_TITLE"
|
||||
|
||||
request POST /api/aihr/personal-assistant/items/url "$TOKEN_A" "$CLIENT_A" \
|
||||
"$(jq -cn --arg url "$PUBLIC_URL" --arg title "$URL_TITLE" '{url:$url,title:$title}')"
|
||||
if [[ "$HTTP_STATUS" != 200 || "$(jq -r '.code // empty' <<<"$HTTP_BODY")" != 200 ]]; then
|
||||
fail "public URL capture failed HTTP=$HTTP_STATUS code=$(jq -r '.code // empty' <<<"$HTTP_BODY") msg=$(jq -r '.msg // empty' <<<"$HTTP_BODY")"
|
||||
fi
|
||||
URL_ITEM_ID="$(jq -er '.data.itemId | tostring' <<<"$HTTP_BODY")"
|
||||
append_item_metadata "$URL_ITEM_ID" "$URL_TITLE"
|
||||
expect_code "${#ITEM_IDS[@]}" 3 "three personal items captured"
|
||||
|
||||
wait_ready "$TEXT_ITEM_ID" "A text item"
|
||||
expect_code "$(jq -r '.data.sourceType' <<<"$HTTP_BODY")" TEXT "A text source type"
|
||||
wait_ready "$PDF_ITEM_ID" "A PDF item"
|
||||
expect_code "$(jq -r '.data.sourceType' <<<"$HTTP_BODY")" FILE "A PDF source type"
|
||||
expect_code "$(jq -r '.data.mimeType' <<<"$HTTP_BODY")" application/pdf "A PDF mime type"
|
||||
wait_ready "$URL_ITEM_ID" "A public URL item"
|
||||
expect_code "$(jq -r '.data.sourceType' <<<"$HTTP_BODY")" URL "A URL source type"
|
||||
URL_ORIGINAL="$(jq -r '.data.originalUrl // empty' <<<"$HTTP_BODY")"
|
||||
[[ "$URL_ORIGINAL" == http://* || "$URL_ORIGINAL" == https://* ]] || fail "A URL originalUrl is not HTTP(S)"
|
||||
[[ -z "$PUBLIC_EXPECTED_URL" ]] || expect_code "$URL_ORIGINAL" "$PUBLIC_EXPECTED_URL" "A URL originalUrl"
|
||||
|
||||
for index in "${!OSS_URLS[@]}"; do
|
||||
assert_anonymous_private "${OSS_URLS[$index]}" "personal object ${ITEM_IDS[$index]}"
|
||||
done
|
||||
assert_search_hit "$TEXT_ITEM_ID" "$TEXT_QUERY" TEXT text
|
||||
assert_search_hit "$PDF_ITEM_ID" "$PDF_QUERY" FILE PDF
|
||||
assert_search_hit "$URL_ITEM_ID" "$PUBLIC_QUERY" URL URL
|
||||
|
||||
if [[ -n "$SCANNED_PDF" ]]; then
|
||||
[[ -f "$SCANNED_PDF" && -s "$SCANNED_PDF" ]] || fail "AIHR_PERSONAL_SCANNED_PDF is not a readable file: $SCANNED_PDF"
|
||||
request_file /api/aihr/personal-assistant/items/file "$TOKEN_A" "$CLIENT_A" "$SCANNED_PDF" "$SCANNED_TITLE"
|
||||
expect_success "A create scanned PDF item"
|
||||
SCANNED_ITEM_ID="$(jq -er '.data.itemId | tostring' <<<"$HTTP_BODY")"
|
||||
append_item_metadata "$SCANNED_ITEM_ID" "$SCANNED_TITLE"
|
||||
wait_scanned_ready "$SCANNED_ITEM_ID"
|
||||
[[ "$(jq -r '.data.ocr.required // false' <<<"$HTTP_BODY")" == true ]] \
|
||||
|| fail "scanned PDF did not enter OCR pipeline"
|
||||
expect_code "$(jq -r '.data.ocr.processedPages // 0' <<<"$HTTP_BODY")" \
|
||||
"$(jq -r '.data.ocr.totalPages // -1' <<<"$HTTP_BODY")" "scanned PDF processed page count"
|
||||
assert_search_hit "$SCANNED_ITEM_ID" "$SCANNED_QUERY" FILE "scanned PDF"
|
||||
request DELETE "/api/aihr/personal-assistant/items/$SCANNED_ITEM_ID" "$TOKEN_A" "$CLIENT_A"
|
||||
expect_success "A delete scanned PDF item"
|
||||
for _ in {1..75}; do
|
||||
scanned_cleanup_done="$(mysql "select count(*) from aihr_personal_cleanup_job
|
||||
where tenant_id='000000' and owner_user_id=$USER_A and item_id=$SCANNED_ITEM_ID and status='DONE'")"
|
||||
[[ "$scanned_cleanup_done" == 1 ]] && break
|
||||
sleep 1
|
||||
done
|
||||
expect_code "$scanned_cleanup_done" 1 "scanned PDF cleanup job completed"
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_ocr_page where tenant_id='000000' and owner_user_id=$USER_A and item_id=$SCANNED_ITEM_ID")" 0 "scanned PDF OCR page residual"
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_ocr_job where tenant_id='000000' and owner_user_id=$USER_A and item_id=$SCANNED_ITEM_ID")" 0 "scanned PDF OCR job residual"
|
||||
fi
|
||||
|
||||
assert_single_item_ask "$TEXT_ITEM_ID" "$TEXT_QUERY" TEXT text
|
||||
assert_single_item_ask "$PDF_ITEM_ID" "$PDF_QUERY" FILE PDF
|
||||
assert_single_item_ask "$URL_ITEM_ID" "$PUBLIC_QUERY" URL URL
|
||||
request GET "/api/aihr/personal-assistant/sessions/$SESSION_ID" "$TOKEN_B" "$CLIENT_B"
|
||||
expect_error PERSONAL_SESSION_NOT_FOUND "B session isolation"
|
||||
|
||||
assert_b_isolation "$TEXT_ITEM_ID" text
|
||||
assert_b_isolation "$PDF_ITEM_ID" PDF
|
||||
assert_b_isolation "$URL_ITEM_ID" URL
|
||||
|
||||
for blocked_url in 'http://127.0.0.1/admin' 'http://169.254.169.254/latest/meta-data/'; do
|
||||
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
|
||||
|
||||
for item_id in "$TEXT_ITEM_ID" "$PDF_ITEM_ID" "$URL_ITEM_ID"; do
|
||||
request DELETE "/api/aihr/personal-assistant/items/$item_id" "$TOKEN_A" "$CLIENT_A"
|
||||
expect_success "A delete item=$item_id"
|
||||
JOB_IDS+=("$(jq -er '.data.cleanupJobId | tostring' <<<"$HTTP_BODY")")
|
||||
request GET "/api/aihr/personal-assistant/items/$item_id" "$TOKEN_A" "$CLIENT_A"
|
||||
expect_error PERSONAL_ITEM_NOT_FOUND "A item=$item_id hidden immediately"
|
||||
done
|
||||
request DELETE "/api/aihr/personal-assistant/items/$TEXT_ITEM_ID" "$TOKEN_A" "$CLIENT_A"
|
||||
expect_success "A repeat text delete"
|
||||
expect_code "$(jq -r '.data.cleanupJobId | tostring' <<<"$HTTP_BODY")" "${JOB_IDS[0]}" "idempotent delete job"
|
||||
|
||||
for _ in {1..75}; do
|
||||
cleanup_done="$(mysql "select count(*) from aihr_personal_cleanup_job
|
||||
where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID)
|
||||
and status='DONE'")"
|
||||
[[ "$cleanup_done" == 3 ]] && break
|
||||
sleep 1
|
||||
done
|
||||
expect_code "$cleanup_done" 3 "three cleanup jobs completed"
|
||||
|
||||
assert_business_cleanup
|
||||
cleanup_once
|
||||
assert_identity_cleanup
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_item where tenant_id='000000' and owner_user_id=$USER_A and title like '$TITLE-%'")" 0 "run items residual"
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_fragment where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID)")" 0 "run fragments residual"
|
||||
expect_code "$(mysql "select count(*) from aihr_personal_cleanup_job where tenant_id='000000' and owner_user_id=$USER_A and item_id in ($TEXT_ITEM_ID,$PDF_ITEM_ID,$URL_ITEM_ID)")" 0 "run cleanup jobs residual"
|
||||
echo "PASS: personal assistant TEXT/PDF/public URL isolation, retrieval, privacy and cleanup gates run=$RUN_ID"
|
||||
|
||||
@@ -20,6 +20,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/ry_job.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/ry_workflow.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
|
||||
|
||||
Reference in New Issue
Block a user