feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user