feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
SILVER_TENANT_ID=""
MEITU_TENANT_ID=""
DRY_RUN=false
CONFIRMED=false
usage() {
echo "Usage: $0 --silver-tenant-id ID --meitu-tenant-id ID [--dry-run | --confirm]" >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--silver-tenant-id) SILVER_TENANT_ID="${2:-}"; shift 2 ;;
--meitu-tenant-id) MEITU_TENANT_ID="${2:-}"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--confirm) CONFIRMED=true; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
valid_tenant_id() {
[[ "$1" =~ ^[A-Za-z0-9_-]{1,20}$ ]]
}
if ! valid_tenant_id "$SILVER_TENANT_ID" || ! valid_tenant_id "$MEITU_TENANT_ID"; then
usage
exit 2
fi
if [[ "$SILVER_TENANT_ID" == "$MEITU_TENANT_ID" ]]; then
echo "银城和美途必须使用不同租户 ID" >&2
exit 2
fi
print_plan() {
echo "[SPACE] tenant=$SILVER_TENANT_ID code=yc_public_policy name=公共制度 type=PUBLIC sensitivity=INTERNAL status=ACTIVE"
echo "[SPACE] tenant=$SILVER_TENANT_ID code=yc_property_sop name=物业业务SOP type=BUSINESS sensitivity=INTERNAL status=ACTIVE"
echo "[SPACE] tenant=$SILVER_TENANT_ID code=yc_management_ops name=管理运营 type=MANAGEMENT sensitivity=CONFIDENTIAL status=ACTIVE"
echo "[SPACE] tenant=$MEITU_TENANT_ID code=mt_customer_service name=美途客户咨询 type=EXTERNAL sensitivity=PUBLIC status=ACTIVE"
echo "[APP] tenant=$SILVER_TENANT_ID code=yc_admin auth=SESSION client=pc status=ACTIVE"
echo "[APP] tenant=$SILVER_TENANT_ID code=yc_mobile auth=SESSION client=app status=ACTIVE"
echo "[APP] tenant=$MEITU_TENANT_ID code=mt_card_miniapp auth=API_TOKEN status=DISABLED"
echo "[BINDING] yc_admin -> yc_public_policy,yc_property_sop,yc_management_ops"
echo "[BINDING] yc_mobile -> yc_public_policy,yc_property_sop,yc_management_ops"
echo "[BINDING] mt_card_miniapp -> mt_customer_service"
echo "[GRANT] employee READ yc_public_policy,yc_property_sop"
echo "[GRANT] supervisor READ yc_public_policy,yc_property_sop,yc_management_ops"
echo "[GRANT] superadmin,hr_operator MANAGE 银城三个空间"
}
if [[ "$DRY_RUN" == true ]]; then
print_plan
exit 0
fi
if [[ "$CONFIRMED" != true ]]; then
echo "实际写入前必须查询 sys_tenant 确认公司名称,并显式传 --confirm" >&2
exit 2
fi
MYSQL_DATABASE="${AIHR_DB_NAME:-ry-vue}"
MYSQL_CONTAINER="${AIHR_MYSQL_CONTAINER:-wygj-mysql}"
MYSQL_USER="${AIHR_DB_USER:-root}"
MYSQL_PASSWORD="${AIHR_DB_PASSWORD:-root}"
mysql_exec() {
if [[ -n "${AIHR_DB_HOST:-}" ]]; then
MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$AIHR_DB_HOST" -P "${AIHR_DB_PORT:-3306}" -u "$MYSQL_USER" \
--default-character-set=utf8mb4 "$MYSQL_DATABASE" "$@"
else
docker exec -i -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" mysql -u "$MYSQL_USER" \
--default-character-set=utf8mb4 "$MYSQL_DATABASE" "$@"
fi
}
tenant_row() {
mysql_exec -Nse "select concat(tenant_id, ':', company_name) from sys_tenant where tenant_id = '$1' and del_flag = '0' limit 1"
}
SILVER_ROW="$(tenant_row "$SILVER_TENANT_ID")"
MEITU_ROW="$(tenant_row "$MEITU_TENANT_ID")"
if [[ -z "$SILVER_ROW" || -z "$MEITU_ROW" ]]; then
echo "sys_tenant 中未找到两个有效租户,请先确认租户 ID" >&2
exit 3
fi
echo "已确认租户:$SILVER_ROW"
echo "已确认租户:$MEITU_ROW"
for app_code in yc_admin yc_mobile mt_card_miniapp; do
other_tenant="$(mysql_exec -Nse "select tenant_id from aihr_knowledge_app where app_code = '$app_code' and tenant_id not in ('$SILVER_TENANT_ID', '$MEITU_TENANT_ID') limit 1")"
if [[ -n "$other_tenant" ]]; then
echo "应用编码 $app_code 已被其他租户占用,停止初始化" >&2
exit 4
fi
done
mysql_exec <<SQL
START TRANSACTION;
INSERT INTO aihr_knowledge_info
(tenant_id, name, code, description, space_type, sensitivity_level, status, \`separator\`, overlap_char,
retrieve_limit, similarity_threshold, text_block_size, enable_hybrid, hybrid_alpha, system_prompt, create_time, update_time, remark)
VALUES
('$SILVER_TENANT_ID', '公共制度', 'yc_public_policy', '员工应知制度、行政通知和公开流程', 'PUBLIC', 'INTERNAL', 'ACTIVE', '\n\n', 120, 5, 0.5, 800, 1, 1.0, '只基于公共制度空间的引用回答。', NOW(), NOW(), 'provision:v1'),
('$SILVER_TENANT_ID', '物业业务 SOP', 'yc_property_sop', '物业岗位流程、标准与话术', 'BUSINESS', 'INTERNAL', 'ACTIVE', '\n\n', 120, 5, 0.5, 800, 1, 1.0, '只基于物业业务 SOP 空间的引用回答。', NOW(), NOW(), 'provision:v1'),
('$SILVER_TENANT_ID', '管理运营', 'yc_management_ops', '项目管理、巡检、培训、品质和团队复盘', 'MANAGEMENT', 'CONFIDENTIAL', 'ACTIVE', '\n\n', 120, 5, 0.5, 800, 1, 1.0, '只基于管理运营空间的引用回答。', NOW(), NOW(), 'provision:v1'),
('$MEITU_TENANT_ID', '美途客户咨询', 'mt_customer_service', '可对外发布的服务介绍、常见问题和客户话术', 'EXTERNAL', 'PUBLIC', 'ACTIVE', '\n\n', 120, 5, 0.5, 800, 1, 1.0, '只回答已发布的美途客户咨询内容,不输出内部资料。', NOW(), NOW(), 'provision:v1')
ON DUPLICATE KEY UPDATE
name = VALUES(name), description = VALUES(description), space_type = VALUES(space_type),
sensitivity_level = VALUES(sensitivity_level), status = VALUES(status), system_prompt = VALUES(system_prompt),
update_time = NOW(), remark = 'provision:v1';
INSERT INTO aihr_knowledge_app
(tenant_id, app_code, app_name, auth_type, internal_client_key, token_hash, status, rate_limit_per_minute, create_time, update_time)
VALUES
('$SILVER_TENANT_ID', 'yc_admin', '银城管理端', 'SESSION', 'pc', NULL, 'ACTIVE', 120, NOW(), NOW()),
('$SILVER_TENANT_ID', 'yc_mobile', '银城员工主管端', 'SESSION', 'app', NULL, 'ACTIVE', 120, NOW(), NOW()),
('$MEITU_TENANT_ID', 'mt_card_miniapp', '美途名片小程序', 'API_TOKEN', NULL, NULL, 'DISABLED', 60, NOW(), NOW())
ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name), internal_client_key = VALUES(internal_client_key),
status = IF(app_code = 'mt_card_miniapp' AND token_hash IS NULL, 'DISABLED', status),
rate_limit_per_minute = VALUES(rate_limit_per_minute), update_time = NOW();
INSERT INTO aihr_knowledge_app_space (tenant_id, app_id, knowledge_id, create_time)
SELECT a.tenant_id, a.id, k.id, NOW()
FROM aihr_knowledge_app a JOIN aihr_knowledge_info k ON k.tenant_id = a.tenant_id
WHERE (a.tenant_id = '$SILVER_TENANT_ID' AND a.app_code IN ('yc_admin','yc_mobile')
AND k.code IN ('yc_public_policy','yc_property_sop','yc_management_ops'))
OR (a.tenant_id = '$MEITU_TENANT_ID' AND a.app_code = 'mt_card_miniapp' AND k.code = 'mt_customer_service')
ON DUPLICATE KEY UPDATE create_time = aihr_knowledge_app_space.create_time;
INSERT INTO aihr_knowledge_space_grant
(tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_time, update_time)
SELECT k.tenant_id, k.id, 'ROLE', g.role_key, g.permission, 'ACTIVE', NOW(), NOW()
FROM aihr_knowledge_info k
JOIN (
SELECT 'employee' role_key, 'READ' permission, 'yc_public_policy' code UNION ALL
SELECT 'employee', 'READ', 'yc_property_sop' UNION ALL
SELECT 'supervisor', 'READ', 'yc_public_policy' UNION ALL
SELECT 'supervisor', 'READ', 'yc_property_sop' UNION ALL
SELECT 'supervisor', 'READ', 'yc_management_ops' UNION ALL
SELECT 'superadmin', 'MANAGE', 'yc_public_policy' UNION ALL
SELECT 'superadmin', 'MANAGE', 'yc_property_sop' UNION ALL
SELECT 'superadmin', 'MANAGE', 'yc_management_ops' UNION ALL
SELECT 'hr_operator', 'MANAGE', 'yc_public_policy' UNION ALL
SELECT 'hr_operator', 'MANAGE', 'yc_property_sop' UNION ALL
SELECT 'hr_operator', 'MANAGE', 'yc_management_ops'
) g ON g.code = k.code
WHERE k.tenant_id = '$SILVER_TENANT_ID'
ON DUPLICATE KEY UPDATE status = 'ACTIVE', update_time = NOW();
COMMIT;
SQL
print_plan
echo "初始化完成;未生成任何 API_TOKEN。请在管理端轮换令牌后再启用美途应用。"
+19 -1
View File
@@ -127,6 +127,24 @@ FROM (
UNION ALL SELECT 'aihr_practice_calibration'
UNION ALL SELECT 'aihr_knowledge_gap'
UNION ALL SELECT 'aihr_knowledge_answer_feedback'
UNION ALL SELECT 'aihr_knowledge_space_grant'
UNION ALL SELECT 'aihr_knowledge_app'
UNION ALL SELECT 'aihr_knowledge_app_space'
UNION ALL SELECT 'aihr_knowledge_query_log'
UNION ALL SELECT 'aihr_knowledge_admin_audit'
UNION ALL SELECT 'aihr_learning_question'
UNION ALL SELECT 'aihr_practice_question_feedback'
UNION ALL SELECT 'aihr_onboard_exam'
UNION ALL SELECT 'aihr_onboard_exam_target'
UNION ALL SELECT 'aihr_onboard_exam_question'
UNION ALL SELECT 'aihr_onboard_exam_attempt'
UNION ALL SELECT 'aihr_onboard_exam_answer'
UNION ALL SELECT 'aihr_web_search_provider'
UNION ALL SELECT 'aihr_web_ai_query_audit'
UNION ALL SELECT 'aihr_community_question'
UNION ALL SELECT 'aihr_community_answer'
UNION ALL SELECT 'aihr_incentive_rule'
UNION ALL SELECT 'aihr_points_ledger'
UNION ALL SELECT 'aihr_candidate_material'
UNION ALL SELECT 'aihr_interview_result'
UNION ALL SELECT 'aihr_candidate_employee_link'
@@ -144,7 +162,7 @@ REMOTE
)" || fail "remote schema check failed: $remote_ssh:$remote_db"
[[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')"
echo "remote_schema=16/16 $remote_ssh:$remote_db"
echo "remote_schema=34/34 $remote_ssh:$remote_db"
}
changed_files="$(git status --porcelain)"
+8 -3
View File
@@ -23,12 +23,17 @@ mysql -e "CREATE DATABASE \`$DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_
mysql "$DB" < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql"
counts="$(mysql "$DB" -N -B -e "
SELECT
(SELECT COUNT(*) FROM aihr_knowledge_attach WHERE type='sop' AND status=2),
(SELECT COUNT(*) FROM aihr_knowledge_fragment WHERE doc_id IN ('sop_service_promotion_v1', 'sop_daily_service_v1'));
(SELECT COUNT(*) FROM aihr_knowledge_fragment WHERE doc_id IN ('sop_service_promotion_v1', 'sop_daily_service_v1')),
(SELECT COUNT(*) FROM aihr_knowledge_info WHERE code IN ('sop_service_promotion', 'sop_daily_service'));
")"
test "$counts" = $'5\t6'
test "$counts" = $'5\t6\t2'
echo "PASS: AIHR BRD SOP migration is idempotent"
echo "PASS: AIHR BRD SOP migration remains idempotent after knowledge platform upgrade"
@@ -38,4 +38,19 @@ counts="$(mysql "$DB" -N -B -e "
")"
test "$counts" = $'2\t2\t10'
echo "PASS: AIHR BRD practice scenarios migration is idempotent"
mysql "$DB" -e "
ALTER TABLE aihr_practice_scenario CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE aihr_practice_rubric CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE aihr_practice_rubric_dimension CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql"
dimension_count="$(mysql "$DB" -N -B -e "
SELECT COUNT(*)
FROM aihr_practice_rubric_dimension
WHERE rubric_code IN ('rubric-service-promotion-needs', 'rubric-daily-service-followup');
")"
test "$dimension_count" = '10'
echo "PASS: AIHR BRD practice scenarios and five-dimension migration are idempotent across legacy collations"
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import http from 'node:http';
import test from 'node:test';
import { once } from 'node:events';
import { verifyKnowledgePlatform } from '../verify-knowledge-platform.mjs';
test('真实HTTP验证器覆盖正例、跨租户反例、伪造身份和限流', async (t) => {
const rateLimit = 3;
let rateProbes = 0;
const server = http.createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
const token = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '');
let code = 200;
let data = null;
if (request.url === '/api/knowledge/admin/spaces') {
data = token === 'admin-token'
? ['yc_public_policy', 'yc_property_sop', 'yc_management_ops'].map((value) => ({ code: value }))
: null;
code = data ? 200 : 401;
} else if (token.endsWith('x') || !['employee-token', 'supervisor-token', 'meitu-token'].includes(token)) {
code = 401;
} else if (body.queryText === 'rate-limit-probe') {
rateProbes += 1;
code = rateProbes > rateLimit ? 429 : 403;
} else if (request.url === '/api/open/knowledge/query' && body.toolCode) {
code = 403;
} else if (body.toolCode === 'TEAM_PRACTICE_SUMMARY' && token === 'employee-token') {
code = 403;
} else if (body.toolCode) {
data = {
citations: [{ sourceType: 'DATA_TOOL', docId: body.toolCode }],
data: { scope: token === 'employee-token' ? 'SELF' : 'TEAM' }
};
} else {
const codeRequested = body.spaceCodes?.[0];
const isMeitu = token === 'meitu-token';
const allowed = isMeitu
? codeRequested === 'mt_customer_service'
: ['yc_public_policy', 'yc_property_sop', 'yc_management_ops'].includes(codeRequested);
const roleAllowed = codeRequested !== 'yc_management_ops' || token === 'supervisor-token';
if (!allowed || !roleAllowed) {
code = 403;
} else {
data = {
usedSpaceCodes: [codeRequested],
citations: [{ sourceType: 'DOCUMENT', spaceCode: codeRequested }]
};
}
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ code, msg: code === 200 ? '操作成功' : 'rejected', data }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
const address = server.address();
const logs = [];
const results = await verifyKnowledgePlatform({
AIHR_BASE_URL: `http://127.0.0.1:${address.port}`,
AIHR_SILVER_ADMIN_TOKEN: 'admin-token',
AIHR_SILVER_EMPLOYEE_TOKEN: 'employee-token',
AIHR_SILVER_SUPERVISOR_TOKEN: 'supervisor-token',
AIHR_MEITU_APP_TOKEN: 'meitu-token',
AIHR_SHARED_DOC_QUERY: 'shared-marker',
AIHR_MEITU_RATE_LIMIT: rateLimit,
AIHR_VERIFY_RATE_LIMIT: true,
AIHR_VERIFY_TIMEOUT_MS: 2_000
}, { log: (line) => logs.push(line) });
assert.equal(results.length, 15);
assert.equal(results.every((item) => item.status === 'PASS'), true);
assert.equal(logs.some((line) => /token/.test(line)), false);
assert.equal(rateProbes, rateLimit + 1);
});
test('服务端安全合同在RAG前拒绝空范围,且身份只取认证上下文', async () => {
const root = new URL('../../', import.meta.url);
const queryService = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java', root), 'utf8');
const queryDto = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java', root), 'utf8');
const accessService = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java', root), 'utf8');
const appService = await readFile(new URL(
'backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java', root), 'utf8');
assert.doesNotMatch(queryDto, /tenantId|userId|extPartyId/);
assert.match(queryService, /principalResolver\.current\(\)/);
assert.match(queryService, /TenantHelper\.dynamic\(app\.tenantId\(\)/);
assert.match(queryService, /resolveInternalSpaceIds[\s\S]*queryDocuments/);
assert.match(accessService, /if \(effective\.isEmpty\(\)\)[\s\S]*throw forbidden/);
assert.match(accessService, /where s\.tenant_id = \? and s\.app_id = \?/);
assert.match(accessService, /where g\.tenant_id = \?/);
assert.match(appService, /expiresTime\(\) == null \|\| row\.expiresTime\(\)\.isAfter/);
assert.match(appService, /RateType\.OVERALL, rate, 60/);
});
test('验证器从内部JWT自动携带clientid且不要求额外密钥配置', async () => {
const source = await readFile(new URL('../verify-knowledge-platform.mjs', import.meta.url), 'utf8');
assert.match(source, /payload\.clientid/);
assert.match(source, /clientid: clientId/);
assert.doesNotMatch(source, /AIHR_.*CLIENT_ID/);
});
test('破坏性解绑验证默认关闭且本地运行器最终撤销临时令牌', async () => {
const verifier = await readFile(new URL('../verify-knowledge-platform.mjs', import.meta.url), 'utf8');
const localRunner = await readFile(new URL('../verify-knowledge-platform-local.mjs', import.meta.url), 'utf8');
assert.match(verifier, /AIHR_VERIFY_UNBIND: env\.AIHR_VERIFY_UNBIND === 'true'/);
assert.match(verifier, /removed\.data\?\.ossDeleted !== false/);
assert.match(verifier, /解绑后其他空间检索/);
assert.match(localRunner, /finally \{[\s\S]*disableTemporaryMeituToken\(\)/);
assert.doesNotMatch(localRunner, /console\.log\([^\n]*externalToken/);
});
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (path) => readFile(new URL(`../../${path}`, import.meta.url), 'utf8');
test('管理端和移动端共用授权查询接口', async () => {
const [admin, mobile] = await Promise.all([
read('frontend/src/api/aihr/sop.ts'),
read('mobile-uni/src/services/knowledge.ts')
]);
assert.match(admin, /url:\s*'\/api\/knowledge\/query'/);
assert.match(mobile, /url:\s*'\/api\/knowledge\/query'/);
for (const source of [admin, mobile]) {
assert.doesNotMatch(source, /tenantId\s*:/);
assert.doesNotMatch(source, /extPartyId\s*:/);
assert.doesNotMatch(source, /projectCodes\s*:/);
assert.doesNotMatch(source, /roles\s*:/);
}
});
test('客户端展示引用与无依据状态,并仅发送固定训练工具', async () => {
const [service, page] = await Promise.all([
read('mobile-uni/src/services/knowledge.ts'),
read('mobile-uni/src/pages/user/sop/index.vue')
]);
assert.match(service, /response\.citations/);
assert.match(service, /noEvidence/);
assert.match(page, /result\.noEvidence/);
assert.match(page, /snippet\.spaceCode/);
assert.match(service, /'MY_PRACTICE_SUMMARY' \| 'TEAM_PRACTICE_SUMMARY'/);
assert.doesNotMatch(page, /v-model="spaceCodes"/);
});
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PAGE="$ROOT_DIR/frontend/src/views/knowledge/processing.vue"
grep -Fq '>批量导入</el-button>' "$PAGE"
grep -Fq ':data="pagedTasks"' "$PAGE"
grep -Fq 'v-model:current-page="taskPage"' "$PAGE"
grep -Fq 'const pagedTasks = computed' "$PAGE"
for removed in '选择目录' '服务端导入' 'webkitdirectory'; do
if grep -Fq "$removed" "$PAGE"; then
echo "FAIL: processing page still contains $removed" >&2
exit 1
fi
done
echo 'PASS: processing page exposes batch import only'
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT_DIR/scripts/provision-knowledge-platform.sh"
if "$SCRIPT" --silver-tenant-id 000000 --dry-run >/dev/null 2>&1; then
echo "missing meitu tenant must fail" >&2
exit 1
fi
OUTPUT="$($SCRIPT --silver-tenant-id 000000 --meitu-tenant-id 100001 --dry-run)"
[[ "$(grep -c '^\[SPACE\]' <<<"$OUTPUT")" -eq 4 ]]
[[ "$(grep -c '^\[APP\]' <<<"$OUTPUT")" -eq 3 ]]
grep -q 'yc_admin -> yc_public_policy,yc_property_sop,yc_management_ops' <<<"$OUTPUT"
grep -q 'yc_mobile -> yc_public_policy,yc_property_sop,yc_management_ops' <<<"$OUTPUT"
grep -q 'mt_card_miniapp -> mt_customer_service' <<<"$OUTPUT"
grep -q 'code=mt_card_miniapp auth=API_TOKEN status=DISABLED' <<<"$OUTPUT"
if grep -Eq 'root/root|ak_[a-z0-9_]+_|token_hash' <<<"$OUTPUT"; then
echo "dry-run leaked a credential or secret field" >&2
exit 1
fi
echo "provision knowledge platform contract passed"
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { verifyKnowledgePlatform } from './verify-knowledge-platform.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
const adminClientId = frontendEnv.VITE_APP_CLIENT_ID;
const requestPublicKey = pem('PUBLIC KEY', frontendEnv.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', frontendEnv.VITE_APP_RSA_PRIVATE_KEY);
if (new URL(baseUrl).hostname.endsWith('.localhost')) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
function readEnv(filePath) {
const values = {};
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return values;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]).toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8')
).toString('base64');
return { body, encryptedKey };
}
function decryptResponse(text, encryptedKey) {
if (!encryptedKey) return JSON.parse(text);
const keyBase64 = crypto.privateDecrypt(
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(encryptedKey, 'base64')
).toString('utf8');
const decipher = crypto.createDecipheriv('aes-256-ecb', Buffer.from(keyBase64, 'base64'), null);
decipher.setAutoPadding(true);
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString('utf8'));
}
async function request(method, endpoint, body, { encrypted = false, clientId = adminClientId } = {}) {
const headers = { clientid: clientId, 'Content-Language': 'zh_CN' };
let payload;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
if (encrypted) {
const value = encryptPayload(body);
headers['encrypt-key'] = value.encryptedKey;
payload = value.body;
} else {
payload = JSON.stringify(body);
}
}
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers,
body: payload,
signal: AbortSignal.timeout(20_000)
});
const text = await response.text();
return encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
}
function docker(container, command) {
return execFileSync('docker', ['exec', container, ...command], { encoding: 'utf8' }).trim();
}
function mysql(sql) {
return docker('wygj-mysql', ['mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql]);
}
async function adminLogin() {
const captcha = await request('GET', '/auth/code');
const uuid = captcha.data?.uuid;
if (!uuid) throw new Error('本地管理员登录未返回验证码UUID');
const redis = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--raw', 'get', `global:captcha_codes:${uuid}`]);
const code = redis.split('\n').pop().replace(/^"|"$/g, '');
const response = await request('POST', '/auth/login', {
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
code,
uuid,
clientId: adminClientId,
grantType: 'password'
}, { encrypted: true });
if (!response.data?.access_token) throw new Error(`本地管理员登录失败: ${response.msg || response.code}`);
return response.data.access_token;
}
async function mobileLogin(phonenumber) {
const mobileClientId = '428a8310cd442757ae699df5d894f051';
const sent = await request('GET', `/resource/sms/code?phonenumber=${encodeURIComponent(phonenumber)}`,
undefined, { clientId: mobileClientId });
if (Number(sent.code) !== 200) throw new Error(`本地手机号 ${phonenumber} 验证码准备失败: ${sent.msg || sent.code}`);
const response = await request('POST', '/auth/mobile/sms-login', {
phonenumber,
smsCode: process.env.AIHR_VERIFY_SMS_CODE || '123456',
tenantId: '000000'
}, { clientId: mobileClientId });
if (!response.data?.access_token) throw new Error(`本地手机号 ${phonenumber} 登录失败: ${response.msg || response.code}`);
return response.data.access_token;
}
async function prepareSharedDocumentFixture(adminToken, fileName, marker) {
const form = new FormData();
form.append('file', new Blob([`${marker}\n员工应遵守基本制度,包括信息保密、考勤和服务规范。\n报修受理后第一步应登记业主信息、报修事项和联系方式并生成工单。\n该文件仅用于验证同一OSS在两个知识空间中的独立成员关系。\n`],
{ type: 'text/plain;charset=utf-8' }), fileName);
form.append('spaceCodes', 'yc_public_policy');
form.append('spaceCodes', 'yc_property_sop');
const response = await fetch(`${baseUrl}/api/knowledge/doc/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}`, clientid: adminClientId },
body: form,
signal: AbortSignal.timeout(60_000)
});
const payload = await response.json();
if (Number(payload.code) !== 200) throw new Error(`双空间测试文档准备失败: ${payload.msg || payload.code}`);
console.log('PASS 双空间测试文档已准备');
}
function installTemporaryMeituToken() {
const token = `ak_mt_card_miniapp_${crypto.randomBytes(32).toString('base64url')}`;
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
mysql(`update aihr_knowledge_app set token_hash='${tokenHash}', status='ACTIVE', rate_limit_per_minute=10, expires_time=now()+interval 1 day where tenant_id='100001' and app_code='mt_card_miniapp' and auth_type='API_TOKEN'`);
return token;
}
function disableTemporaryMeituToken() {
mysql("update aihr_knowledge_app set token_hash=null, status='DISABLED', rate_limit_per_minute=60, expires_time=null where tenant_id='100001' and app_code='mt_card_miniapp'");
}
function clearLocalAppRateLimit() {
const output = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--scan', '--pattern', '*knowledge:app:*']);
for (const key of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', 'del', key]);
}
}
function clearLocalMobileAuthFixture() {
const output = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--scan', '--pattern', '*1390000000*']);
for (const key of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', 'del', key]);
}
}
async function expectExternalRejected(token, label) {
const response = await fetch(`${baseUrl}/api/open/knowledge/query`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ queryText: '认证状态验证', spaceCodes: ['mt_customer_service'] }),
signal: AbortSignal.timeout(20_000)
});
const payload = await response.json();
if (Number(payload.code) !== 401) throw new Error(`${label}: 期望业务码401,实际${payload.code}`);
console.log(`PASS ${label}`);
}
async function main() {
const externalToken = installTemporaryMeituToken();
clearLocalAppRateLimit();
clearLocalMobileAuthFixture();
try {
const [adminToken, employeeToken, supervisorToken] = await Promise.all([
adminLogin(), mobileLogin('13900000001'), mobileLogin('13900000002')
]);
const sharedDocumentName = process.env.AIHR_SHARED_DOC_NAME || 'knowledge-platform-shared-verification.txt';
const sharedDocumentQuery = process.env.AIHR_SHARED_DOC_QUERY || '知识空间共享验证标记';
if (process.env.AIHR_VERIFY_UNBIND === 'true') {
await prepareSharedDocumentFixture(adminToken, sharedDocumentName, sharedDocumentQuery);
}
const results = await verifyKnowledgePlatform({
AIHR_BASE_URL: baseUrl,
AIHR_SILVER_ADMIN_TOKEN: adminToken,
AIHR_SILVER_EMPLOYEE_TOKEN: employeeToken,
AIHR_SILVER_SUPERVISOR_TOKEN: supervisorToken,
AIHR_MEITU_APP_TOKEN: externalToken,
AIHR_SHARED_DOC_QUERY: sharedDocumentQuery,
AIHR_MEITU_QUERY: process.env.AIHR_MEITU_QUERY || 'MEITU_VERIFY_20260716',
AIHR_MEITU_RATE_LIMIT: 10,
AIHR_VERIFY_RATE_LIMIT: true,
AIHR_VERIFY_UNBIND: process.env.AIHR_VERIFY_UNBIND === 'true',
AIHR_SHARED_DOC_NAME: sharedDocumentName,
AIHR_VERIFY_TIMEOUT_MS: 20_000
});
mysql("update aihr_knowledge_app set status='DISABLED' where tenant_id='100001' and app_code='mt_card_miniapp'");
await expectExternalRejected(externalToken, '停用应用令牌被拒绝');
mysql("update aihr_knowledge_app set status='ACTIVE', expires_time=now()-interval 1 minute where tenant_id='100001' and app_code='mt_card_miniapp'");
await expectExternalRejected(externalToken, '过期应用令牌被拒绝');
console.log(`LOCAL_HTTP_RESULT ${results.length + 2}/${results.length + 2}`);
} finally {
disableTemporaryMeituToken();
}
}
main().catch((error) => {
console.error(`本地知识平台验证失败: ${error.message}`);
process.exitCode = 1;
});
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
const DEFAULT_TIMEOUT_MS = 20_000;
function clientIdFromToken(token) {
try {
const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
return typeof payload.clientid === 'string' ? payload.clientid : '';
} catch {
return '';
}
}
function authorizationHeaders(token) {
const clientId = clientIdFromToken(token);
return {
Authorization: `Bearer ${token}`,
...(clientId ? { clientid: clientId } : {})
};
}
function required(config, key) {
const value = config[key];
if (!value) throw new Error(`缺少必需配置 ${key}`);
return value;
}
async function postJson(baseUrl, path, token, body, fetchImpl, timeoutMs) {
const response = await fetchImpl(new URL(path, `${baseUrl.replace(/\/$/, '')}/`), {
method: 'POST',
headers: {
...authorizationHeaders(token),
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs)
});
const payload = await response.json().catch(() => ({}));
return { httpStatus: response.status, code: Number(payload.code), message: payload.msg ?? '', data: payload.data };
}
async function getJson(baseUrl, path, token, fetchImpl, timeoutMs) {
const response = await fetchImpl(new URL(path, `${baseUrl.replace(/\/$/, '')}/`), {
headers: authorizationHeaders(token),
signal: AbortSignal.timeout(timeoutMs)
});
const payload = await response.json().catch(() => ({}));
return { httpStatus: response.status, code: Number(payload.code), message: payload.msg ?? '', data: payload.data };
}
async function deleteJson(baseUrl, path, token, fetchImpl, timeoutMs) {
const response = await fetchImpl(new URL(path, `${baseUrl.replace(/\/$/, '')}/`), {
method: 'DELETE',
headers: authorizationHeaders(token),
signal: AbortSignal.timeout(timeoutMs)
});
const payload = await response.json().catch(() => ({}));
return { httpStatus: response.status, code: Number(payload.code), message: payload.msg ?? '', data: payload.data };
}
function ensureCode(result, expected, label) {
if (!expected.includes(result.code)) {
throw new Error(`${label}: 期望业务码 ${expected.join('/')},实际 ${result.code || '非JSON响应'} (${result.httpStatus})`);
}
}
function ensureSpaces(result, expected, label) {
const used = new Set(result.data?.usedSpaceCodes ?? []);
const citations = new Set((result.data?.citations ?? []).map((item) => item.spaceCode).filter(Boolean));
for (const code of expected) {
if (!used.has(code) || !citations.has(code)) {
throw new Error(`${label}: 响应未同时声明并引用空间 ${code}`);
}
}
}
function ensureTool(result, toolCode, label) {
const citation = (result.data?.citations ?? []).find((item) => item.sourceType === 'DATA_TOOL');
if (!citation || citation.docId !== toolCode || result.data?.data == null) {
throw new Error(`${label}: 未返回受控数据工具 ${toolCode} 的引用与最小结果`);
}
}
export function configFromEnv(env = process.env) {
return {
AIHR_BASE_URL: env.AIHR_BASE_URL,
AIHR_SILVER_ADMIN_TOKEN: env.AIHR_SILVER_ADMIN_TOKEN,
AIHR_SILVER_EMPLOYEE_TOKEN: env.AIHR_SILVER_EMPLOYEE_TOKEN,
AIHR_SILVER_SUPERVISOR_TOKEN: env.AIHR_SILVER_SUPERVISOR_TOKEN,
AIHR_MEITU_APP_TOKEN: env.AIHR_MEITU_APP_TOKEN,
AIHR_SHARED_DOC_QUERY: env.AIHR_SHARED_DOC_QUERY || '知识空间共享验证标记',
AIHR_MEITU_QUERY: env.AIHR_MEITU_QUERY || '你们提供哪些客户服务?',
AIHR_MEITU_RATE_LIMIT: Number(env.AIHR_MEITU_RATE_LIMIT || 60),
AIHR_VERIFY_RATE_LIMIT: env.AIHR_VERIFY_RATE_LIMIT !== 'false',
AIHR_VERIFY_UNBIND: env.AIHR_VERIFY_UNBIND === 'true',
AIHR_SHARED_DOC_NAME: env.AIHR_SHARED_DOC_NAME || '',
AIHR_VERIFY_TIMEOUT_MS: Number(env.AIHR_VERIFY_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)
};
}
export async function verifyKnowledgePlatform(config, { fetchImpl = fetch, log = console.log } = {}) {
const baseUrl = required(config, 'AIHR_BASE_URL');
const admin = required(config, 'AIHR_SILVER_ADMIN_TOKEN');
const employee = required(config, 'AIHR_SILVER_EMPLOYEE_TOKEN');
const supervisor = required(config, 'AIHR_SILVER_SUPERVISOR_TOKEN');
const meitu = required(config, 'AIHR_MEITU_APP_TOKEN');
const timeoutMs = Number(config.AIHR_VERIFY_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
const results = [];
const check = async (label, operation) => {
await operation();
results.push({ label, status: 'PASS' });
log(`PASS ${label}`);
};
await check('管理员只看到银城三个标准空间', async () => {
const result = await getJson(baseUrl, '/api/knowledge/admin/spaces', admin, fetchImpl, timeoutMs);
ensureCode(result, [200], '管理员空间');
const codes = new Set((result.data ?? []).map((item) => item.code));
for (const code of ['yc_public_policy', 'yc_property_sop', 'yc_management_ops']) {
if (!codes.has(code)) throw new Error(`管理员空间: 缺少 ${code}`);
}
if (codes.has('mt_customer_service')) throw new Error('管理员空间: 出现美途跨租户空间');
});
await check('银城员工查询公共制度', async () => {
const result = await postJson(baseUrl, '/api/knowledge/query', employee,
{ queryText: '员工应遵守哪些基本制度?', spaceCodes: ['yc_public_policy'], limit: 5 }, fetchImpl, timeoutMs);
ensureCode(result, [200], '员工公共制度');
ensureSpaces(result, ['yc_public_policy'], '员工公共制度');
});
await check('银城员工查询物业业务SOP', async () => {
const result = await postJson(baseUrl, '/api/knowledge/query', employee,
{ queryText: '报修受理后第一步做什么?', spaceCodes: ['yc_property_sop'], limit: 5 }, fetchImpl, timeoutMs);
ensureCode(result, [200], '员工物业SOP');
ensureSpaces(result, ['yc_property_sop'], '员工物业SOP');
});
await check('银城主管查询管理运营', async () => {
const result = await postJson(baseUrl, '/api/knowledge/query', supervisor,
{ queryText: '主管怎样组织服务复盘?', spaceCodes: ['yc_management_ops'], limit: 5 }, fetchImpl, timeoutMs);
ensureCode(result, [200], '主管管理运营');
ensureSpaces(result, ['yc_management_ops'], '主管管理运营');
});
await check('员工只查询本人训练概况', async () => {
const result = await postJson(baseUrl, '/api/knowledge/query', employee,
{ queryText: '我的训练概况', toolCode: 'MY_PRACTICE_SUMMARY', userId: 'forged-user', extPartyId: 'forged-party' },
fetchImpl, timeoutMs);
ensureCode(result, [200], '本人训练概况');
ensureTool(result, 'MY_PRACTICE_SUMMARY', '本人训练概况');
});
await check('主管只查询服务端团队范围', async () => {
const result = await postJson(baseUrl, '/api/knowledge/query', supervisor,
{ queryText: '团队训练概况', toolCode: 'TEAM_PRACTICE_SUMMARY', tenantId: 'forged', extPartyId: 'forged-party' },
fetchImpl, timeoutMs);
ensureCode(result, [200], '团队训练概况');
ensureTool(result, 'TEAM_PRACTICE_SUMMARY', '团队训练概况');
});
await check('美途应用只返回美途客户咨询引用', async () => {
const result = await postJson(baseUrl, '/api/open/knowledge/query', meitu,
{ queryText: config.AIHR_MEITU_QUERY || '你们提供哪些客户服务?', spaceCodes: ['mt_customer_service'], limit: 5 },
fetchImpl, timeoutMs);
ensureCode(result, [200], '美途客户咨询');
ensureSpaces(result, ['mt_customer_service'], '美途客户咨询');
});
await check('同文件两个银城空间可分别检索', async () => {
for (const code of ['yc_public_policy', 'yc_property_sop']) {
const result = await postJson(baseUrl, '/api/knowledge/query', employee,
{ queryText: config.AIHR_SHARED_DOC_QUERY, spaceCodes: [code], limit: 5 }, fetchImpl, timeoutMs);
ensureCode(result, [200], `共享文件 ${code}`);
ensureSpaces(result, [code], `共享文件 ${code}`);
}
});
const negativeCases = [
['银城身份不能请求美途空间', '/api/knowledge/query', employee,
{ queryText: '越权测试', spaceCodes: ['mt_customer_service'] }, 403],
['美途应用不能请求银城空间', '/api/open/knowledge/query', meitu,
{ queryText: '越权测试', spaceCodes: ['yc_public_policy'] }, 403],
['员工不能查询团队训练', '/api/knowledge/query', employee,
{ queryText: '团队训练', toolCode: 'TEAM_PRACTICE_SUMMARY' }, 403],
['外部应用不能调用内部数据工具', '/api/open/knowledge/query', meitu,
{ queryText: '我的训练', toolCode: 'MY_PRACTICE_SUMMARY' }, 403],
['错误应用令牌被拒绝', '/api/open/knowledge/query', `${meitu}x`,
{ queryText: '认证测试', spaceCodes: ['mt_customer_service'] }, 401]
];
for (const [label, path, token, body, expected] of negativeCases) {
await check(label, async () => {
const result = await postJson(baseUrl, path, token, body, fetchImpl, timeoutMs);
ensureCode(result, [expected], label);
});
}
await check('伪造身份字段不改变服务端空间范围', async () => {
const result = await postJson(baseUrl, '/api/knowledge/query', employee, {
queryText: '员工应遵守哪些基本制度?',
spaceCodes: ['yc_public_policy'], tenantId: '100001', userId: '999999', extPartyId: 'supervisor'
}, fetchImpl, timeoutMs);
ensureCode(result, [200], '伪造身份字段');
ensureSpaces(result, ['yc_public_policy'], '伪造身份字段');
});
if (config.AIHR_VERIFY_RATE_LIMIT !== false) {
await check('美途应用超过限流返回429', async () => {
const limit = Number(config.AIHR_MEITU_RATE_LIMIT || 60);
if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) throw new Error('AIHR_MEITU_RATE_LIMIT 无效');
let saw429 = false;
for (let index = 0; index < limit + 1; index += 1) {
const result = await postJson(baseUrl, '/api/open/knowledge/query', meitu,
{ queryText: 'rate-limit-probe', spaceCodes: ['yc_public_policy'] }, fetchImpl, timeoutMs);
if (result.code === 429) {
saw429 = true;
break;
}
ensureCode(result, [403], '限流探测前置请求');
}
if (!saw429) throw new Error(`限流探测: ${limit + 1} 次请求内未出现 429`);
});
}
if (config.AIHR_VERIFY_UNBIND) {
await check('解绑一个空间后其他空间成员与OSS仍保留', async () => {
const targetName = required(config, 'AIHR_SHARED_DOC_NAME');
const spacesResult = await getJson(baseUrl, '/api/knowledge/admin/spaces', admin, fetchImpl, timeoutMs);
ensureCode(spacesResult, [200], '解绑验证空间列表');
const publicSpace = (spacesResult.data ?? []).find((item) => item.code === 'yc_public_policy');
if (!publicSpace?.id) throw new Error('解绑验证: 找不到公共制度空间ID');
const before = await getJson(baseUrl, `/api/knowledge/admin/spaces/${publicSpace.id}/documents`, admin, fetchImpl, timeoutMs);
ensureCode(before, [200], '解绑验证成员列表');
const target = (before.data ?? []).find((item) => item.name === targetName);
if (!target?.attachId) throw new Error(`解绑验证: 当前空间找不到文档 ${targetName}`);
const removed = await deleteJson(baseUrl,
`/api/knowledge/admin/spaces/${publicSpace.id}/documents/${target.attachId}`, admin, fetchImpl, timeoutMs);
ensureCode(removed, [200], '解绑当前空间成员');
if (removed.data?.ossDeleted !== false) throw new Error('解绑验证: 仍有其他空间引用时不应删除OSS');
const after = await getJson(baseUrl, `/api/knowledge/admin/spaces/${publicSpace.id}/documents`, admin, fetchImpl, timeoutMs);
ensureCode(after, [200], '解绑后成员列表');
if ((after.data ?? []).some((item) => item.attachId === target.attachId)) {
throw new Error('解绑验证: 当前空间成员仍然存在');
}
const remaining = await postJson(baseUrl, '/api/knowledge/query', employee,
{ queryText: config.AIHR_SHARED_DOC_QUERY, spaceCodes: ['yc_property_sop'], limit: 5 }, fetchImpl, timeoutMs);
ensureCode(remaining, [200], '解绑后其他空间检索');
ensureSpaces(remaining, ['yc_property_sop'], '解绑后其他空间检索');
});
}
return results;
}
async function main() {
const results = await verifyKnowledgePlatform(configFromEnv());
console.log(`知识平台验证完成:${results.length}/${results.length} PASS`);
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().catch((error) => {
console.error(`知识平台验证失败:${error.message}`);
process.exitCode = 1;
});
}