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
+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"