diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java index 8461a3c9..7d78d117 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java @@ -10,6 +10,7 @@ import org.dromara.common.oss.enums.AccessPolicyType; import org.dromara.common.oss.factory.OssFactory; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.Scheduled; @@ -38,6 +39,7 @@ public class PersonalIngestionWorker { private final int parsingLeaseMinutes; private final int maxParseAttempts; + @Autowired public PersonalIngestionWorker(JdbcTemplate jdbcTemplate, ISysOssService ossService, KnowledgeDocumentParser parser, PersonalKnowledgeProperties properties, PlatformTransactionManager transactionManager) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java index c3f7b870..b3553547 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java @@ -2,6 +2,7 @@ package org.dromara.aihr.personal.service; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.common.core.exception.ServiceException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.naming.Context; @@ -90,6 +91,7 @@ public class PersonalUrlFetchService { private final Resolver resolver; private final Fetcher fetcher; + @Autowired public PersonalUrlFetchService(PersonalKnowledgeProperties properties) { this(properties, new DeadlineDnsResolver(DNS_EXECUTOR, new JndiDnsQuery()), new RawSocketFetcher()); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java index a8f38468..5004ff76 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.aihr.personal.support.PersonalOwner; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.net.URI; @@ -36,6 +37,7 @@ public class PersonalVectorStore { private final HttpTransport transport; private final String collection; + @Autowired public PersonalVectorStore(PersonalKnowledgeProperties properties, ObjectMapper objectMapper) { this(properties, objectMapper, javaTransport(properties)); } diff --git a/backend/script/sql/aihr_personal_knowledge_mysql8.sql b/backend/script/sql/aihr_personal_knowledge_mysql8.sql index 12319323..eddd6c89 100644 --- a/backend/script/sql/aihr_personal_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_personal_knowledge_mysql8.sql @@ -1,6 +1,11 @@ -- 个人 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'; + CREATE TABLE IF NOT EXISTS `aihr_personal_space` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 4a5c8a1b..28149578 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -170,3 +170,10 @@ curl -fsS -X POST "$API_BASE/api/knowledge/doc/rebuild-vector-index" -H "Authori ``` 浏览器验收仍按 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) 的四条关键路径执行。 +# 个人 AI 助理 P0 API + +个人知识接口统一位于 `/api/aihr/personal-assistant`,包括空间、资料列表与详情、文字/文件/URL 收藏、重试、删除、下载链接、检索、问答和会话。所有接口必须使用移动端短信登录返回的 `Authorization: Bearer ` 与 `clientid`,服务端从登录态注入 `tenant_id + owner_user_id`;请求体和查询参数不接受调用方指定 owner。 + +`POST /search` 与 `POST /ask` 的 `scope` 可选 `PERSONAL`、`ENTERPRISE` 或二者组合。返回引用的 `domain` 必须明确为个人或企业域;企业域只有在服务端企业知识授权策略返回显式 fragment allowlist 后才会参与 mixed 检索,未配置授权时 fail-closed。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。 + +URL 收藏在任何网络请求前执行 SSRF 校验,私网、环回、链路本地和云元数据地址返回 `PERSONAL_URL_BLOCKED`。删除先进入 `DELETING` 并立即对详情和检索隐藏,再异步清理 Qdrant、片段和 OSS;重复删除返回同一个 `cleanupJobId`。 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 73b7d893..ccfc1ba3 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -50,3 +50,8 @@ - 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。 - 不承诺管理端 AI 面试、案例沉淀已写入正式业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;候选人补充资料写 `sys_oss` + `aihr_candidate_material`,HR 可审核状态,但不做复杂审批流转;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。 - 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。 +# 个人 AI 助理 P0 验收 + +先运行 `./scripts/personal-assistant-smoke.sh`,必须输出 `PASS`。浏览器使用手机号 A 登录后,依次收藏文字、PDF 与公开网页,等待资料状态变为 READY;按采集日期检索,并分别验证个人、企业与 mixed 问答的引用域。删除个人资料后,详情和搜索应立即不可见。 + +再使用手机号 B 登录,确认看不到 A 的资料标题、会话与引用,且不能访问 A 的详情、下载、重试或删除接口。私网 URL 与云元数据 URL 必须显示明确的 `PERSONAL_URL_BLOCKED`,回答不得出现无引用内容。企业知识未配置明确授权 allowlist 时,ENTERPRISE/mixed 必须 fail-closed。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index e844753b..34c4fe6c 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -194,3 +194,17 @@ 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`。 + +空间与处理配额通过 `aihr.personal.*` 配置覆盖,包括空间字节配额、资料数量、单文件大小、抓取字节/超时、worker 与 cleanup 批量大小。不要把个人 collection 改回企业 `aihr_knowledge`。 + +启动依赖与后端后运行真实隔离 smoke: + +```bash +./scripts/dev.sh +./scripts/personal-assistant-smoke.sh +``` + +脚本用固定 smoke 手机号通过开发短信登录创建 A/B,验证 owner 隔离、READY、SSRF 和幂等删除,并在退出时清理 smoke 数据。脚本不会输出 token。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index f632c2fc..2f96b8cf 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -367,6 +367,10 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/embeddings" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap" +contains backend/script/sql/aihr_personal_knowledge_mysql8.sql "aihr_personal_fragment" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java "owner_user_id" +contains mobile-uni/src/pages/user/assistant/index.vue "我的资料" +contains scripts/personal-assistant-smoke.sh "PERSONAL_ITEM_NOT_FOUND" contains backend/script/sql/aihr_knowledge_mysql8.sql "embedding_json" contains frontend/src/views/knowledge/sop.vue "上传文档" contains frontend/src/views/knowledge/sop.vue ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" diff --git a/scripts/personal-assistant-smoke.sh b/scripts/personal-assistant-smoke.sh new file mode 100755 index 00000000..e5f03a96 --- /dev/null +++ b/scripts/personal-assistant-smoke.sh @@ -0,0 +1,170 @@ +#!/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="个人助理隔离烟测唯一标记,物业催费沟通资料。" + +TOKEN_A="" +CLIENT_A="" +TOKEN_B="" +CLIENT_B="" +USER_A="" +USER_B="" +ITEM_ID="" + +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 +} + +cleanup() { + local exit_code=$? + local oss_rows oss_id object_key + 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" \ + -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 + 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 + 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 + done + return "$exit_code" +} +trap cleanup EXIT INT 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") + [[ -z "$body" ]] || args+=(-H 'Content-Type: application/json' --data "$body") + curl "${args[@]}" +} + +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 ' ' - +} + +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" +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" + +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" + +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")" + +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 + 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" +} + +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)" ;; + esac + expect_code "$(jq -r '.msg // empty' <<<"$response")" "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" + +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" +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"