feat: restore learning feedback and knowledge capabilities
This commit is contained in:
Executable
+158
@@ -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。请在管理端轮换令牌后再启用美途应用。"
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
Executable
+218
@@ -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;
|
||||
});
|
||||
Executable
+272
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user