feat(aihr): complete uni-app role workflows
This commit is contained in:
+25
-22
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:5173}"
|
||||
MOBILE_URL="${MOBILE_URL:-http://127.0.0.1:5174}"
|
||||
MOBILE_URL="${MOBILE_URL:-https://wygj-mobile-uni.localhost}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
@@ -26,7 +26,7 @@ check_url() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local code
|
||||
code="$(curl -sS -o /tmp/wygj-demo-check.body -w '%{http_code}' "$url")"
|
||||
code="$(curl -k -sS -o /tmp/wygj-demo-check.body -w '%{http_code}' "$url")"
|
||||
[[ "$code" == "200" ]] || fail "$name returned HTTP $code"
|
||||
echo "OK: $name"
|
||||
}
|
||||
@@ -75,7 +75,7 @@ check_pilot_samples() {
|
||||
need curl
|
||||
|
||||
check_url "frontend" "$FRONTEND_URL/"
|
||||
check_url "mobile" "$MOBILE_URL/h5/user"
|
||||
check_url "mobile-uni" "$MOBILE_URL/h5/"
|
||||
|
||||
tenant_payload="$(curl -fsS "$FRONTEND_URL/dev-api/auth/tenant/list")"
|
||||
[[ "$tenant_payload" == *'"tenantId":"000000"'* ]] || fail "tenant list missing tenant 000000"
|
||||
@@ -91,20 +91,23 @@ contains frontend/src/router/index.ts "/knowledge/cases"
|
||||
contains frontend/src/router/index.ts "/knowledge/sop"
|
||||
contains frontend/src/router/index.ts "/system/model"
|
||||
|
||||
contains mobile/src/App.vue "早上好,王敏"
|
||||
contains mobile/src/App.vue "AI结构化面试"
|
||||
contains mobile/src/App.vue "团队今日动态"
|
||||
contains mobile/src/App.vue "sendSmsCode"
|
||||
contains mobile/src/App.vue "/dev-api/resource/sms/code"
|
||||
contains mobile/src/App.vue "/dev-api/auth/mobile/sms-login"
|
||||
contains mobile/src/App.vue "fetchMobileHome"
|
||||
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/home/"
|
||||
contains mobile/src/App.vue "startMobilePractice"
|
||||
contains mobile/src/App.vue "/dev-api/api/train/practice/start"
|
||||
contains mobile/src/App.vue "训练完成,主管端待复盘已更新"
|
||||
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/practice/history"
|
||||
contains mobile/src/App.vue "competencyProfile"
|
||||
contains mobile/package.json "aihr-mobile"
|
||||
contains mobile-uni/package.json "aihr-mobile-uni"
|
||||
contains mobile-uni/src/pages.json "pages/user/today/index"
|
||||
contains mobile-uni/src/pages.json "pages/supervisor/index/index"
|
||||
contains mobile-uni/src/pages.json "pages/candidate/index/index"
|
||||
contains mobile-uni/src/pages/auth/login/index.vue "/resource/sms/code"
|
||||
contains mobile-uni/src/pages/auth/login/index.vue "/auth/mobile/sms-login"
|
||||
contains mobile-uni/src/services/home.ts "/api/aihr/mobile/home/"
|
||||
contains mobile-uni/src/services/practice.ts "/api/train/practice/start"
|
||||
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/history"
|
||||
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/profile/promotion-evidence"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "训练完成,主管端待复盘已更新"
|
||||
contains mobile-uni/src/pages/user/profile/index.vue "贡献度"
|
||||
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/answer-feedback"
|
||||
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
|
||||
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
|
||||
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
|
||||
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
|
||||
contains frontend/src/views/recruit/interview.vue "建议复试"
|
||||
contains frontend/src/views/train/practice.vue "导师改写"
|
||||
contains frontend/src/views/knowledge/cases.vue "真实语音"
|
||||
@@ -133,21 +136,21 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")'
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource"
|
||||
contains frontend/src/views/train/practice.vue "practice_help"
|
||||
contains mobile/src/App.vue "practice_help"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
||||
contains mobile/src/App.vue "新员工每日三题"
|
||||
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "incentive_point"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "cost-guard-fallback"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
|
||||
contains mobile/src/App.vue "贡献度加分"
|
||||
contains mobile-uni/src/pages/supervisor/index/index.vue "人工贡献分"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
|
||||
contains mobile/src/App.vue "标记已复盘"
|
||||
contains mobile-uni/src/pages/supervisor/index/index.vue "标记已复盘"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText"
|
||||
contains mobile/src/App.vue "submitDailyDrill"
|
||||
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"
|
||||
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/upload"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
|
||||
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search"
|
||||
|
||||
+21
-5
@@ -2,12 +2,28 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if [[ -f "$ROOT_DIR/.env.local" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT_DIR/.env.local"
|
||||
set +a
|
||||
load_env_file() {
|
||||
local env_file="$1"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$env_file"
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
|
||||
load_env_file "$ROOT_DIR/.env.local"
|
||||
|
||||
if [[ -f "$ROOT_DIR/backend/.env" ]]; then
|
||||
load_env_file "$ROOT_DIR/backend/.env"
|
||||
if [[ -n "${client_id:-}" ]]; then
|
||||
export AIHR_ORG_SYNC_CLIENT_ID="$client_id"
|
||||
fi
|
||||
if [[ -n "${client_secret:-}" ]]; then
|
||||
export AIHR_ORG_SYNC_CLIENT_SECRET="$client_secret"
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/backend"
|
||||
|
||||
+4
-3
@@ -8,7 +8,7 @@ usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/dev.sh [start|stop|restart|status|--reset]
|
||||
|
||||
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and mobile frontend.
|
||||
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and legacy mobile fallback.
|
||||
start start local demo services
|
||||
stop stop backend and frontend dev processes
|
||||
restart stop backend and frontend, then start again
|
||||
@@ -148,9 +148,10 @@ PIDS+=("$!")
|
||||
|
||||
echo "Backend: http://127.0.0.1:8080/"
|
||||
echo "Admin: http://127.0.0.1:5173/"
|
||||
echo "Mobile: http://127.0.0.1:5174/h5/user"
|
||||
echo "Legacy mobile fallback: http://127.0.0.1:5174/h5/user"
|
||||
echo "Current user side: https://wygj-mobile-uni.localhost/h5/ (run: portless)"
|
||||
echo "Qdrant: http://127.0.0.1:6333/"
|
||||
echo "Press Ctrl+C to stop backend, admin frontend and mobile frontend."
|
||||
echo "Press Ctrl+C to stop backend, admin frontend and legacy mobile frontend."
|
||||
|
||||
while :; do
|
||||
for pid in "${PIDS[@]}"; do
|
||||
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
const args = new Map(process.argv.slice(2).map((arg) => {
|
||||
const [key, ...rest] = arg.replace(/^--/, '').split('=');
|
||||
return [key, rest.join('=') || 'true'];
|
||||
}));
|
||||
|
||||
const baseUrl = args.get('base-url') || process.env.AIHR_BASE_URL || 'http://127.0.0.1:8080';
|
||||
const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演示候选题单.md');
|
||||
const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean);
|
||||
const limit = Number(args.get('limit') || 0);
|
||||
|
||||
function readEnv(filePath) {
|
||||
const env = {};
|
||||
const text = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
|
||||
if (!match) continue;
|
||||
env[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function pem(label, body) {
|
||||
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
|
||||
const clientid = process.env.AIHR_CLIENT_ID || 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);
|
||||
|
||||
function aesEncrypt(text, keyText) {
|
||||
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
|
||||
cipher.setAutoPadding(true);
|
||||
return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64');
|
||||
}
|
||||
|
||||
function aesDecrypt(base64Text, key) {
|
||||
const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
|
||||
decipher.setAutoPadding(true);
|
||||
return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
function encryptPayload(payload) {
|
||||
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
|
||||
const encryptedBody = aesEncrypt(JSON.stringify(payload), keyText);
|
||||
const keyBase64 = Buffer.from(keyText, 'utf8').toString('base64');
|
||||
const encryptedKey = crypto.publicEncrypt(
|
||||
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
||||
Buffer.from(keyBase64, 'utf8')
|
||||
).toString('base64');
|
||||
return { encryptedBody, 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');
|
||||
return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64')));
|
||||
}
|
||||
|
||||
async function request(method, endpoint, body, token, encrypted = false) {
|
||||
const headers = { clientid, 'Content-Language': 'zh_CN' };
|
||||
let payload;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||
if (encrypted) {
|
||||
const encryptedPayload = encryptPayload(body);
|
||||
payload = encryptedPayload.encryptedBody;
|
||||
headers['encrypt-key'] = encryptedPayload.encryptedKey;
|
||||
} else {
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 70000);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, { method, headers, body: payload, signal: controller.signal });
|
||||
const text = await response.text();
|
||||
const data = encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
|
||||
return { status: response.status, data };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN;
|
||||
const codeResponse = await request('GET', '/auth/code');
|
||||
const uuid = codeResponse.data?.data?.uuid;
|
||||
if (!uuid) throw new Error('captcha uuid missing');
|
||||
const redisKey = `global:captcha_codes:${uuid}`;
|
||||
const redisValue = execFileSync(
|
||||
'docker',
|
||||
['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', redisKey],
|
||||
{ encoding: 'utf8' }
|
||||
).trim().split('\n').pop().replace(/^"|"$/g, '');
|
||||
const loginResponse = await request('POST', '/auth/login', {
|
||||
tenantId: '000000',
|
||||
username: process.env.AIHR_VERIFY_USER || 'admin',
|
||||
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
|
||||
rememberMe: false,
|
||||
code: redisValue,
|
||||
uuid,
|
||||
clientId: clientid,
|
||||
grantType: 'password'
|
||||
}, undefined, true);
|
||||
const token = loginResponse.data?.data?.access_token;
|
||||
if (!token) throw new Error(`login failed: ${loginResponse.data?.msg || loginResponse.status}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
function parseQuestions(markdown) {
|
||||
const rows = [];
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
if (!line.startsWith('|')) continue;
|
||||
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
|
||||
if (cells.length < 5 || !/^[A-Z]\d+$/u.test(cells[0])) continue;
|
||||
if (!prefixes.some((prefix) => cells[0].startsWith(prefix))) continue;
|
||||
rows.push({
|
||||
id: cells[0],
|
||||
scene: cells[1],
|
||||
question: cells[2],
|
||||
sources: [...cells[3].matchAll(/`([^`]+)`/g)].map((match) => match[1])
|
||||
});
|
||||
}
|
||||
return limit > 0 ? rows.slice(0, limit) : rows;
|
||||
}
|
||||
|
||||
function hasExpectedSource(result, expectedSources) {
|
||||
if (!expectedSources.length) return true;
|
||||
const haystack = [
|
||||
...(result.docs || []).map((item) => item.title || item.docName || item.name || ''),
|
||||
...(result.snippets || []).map((item) => item.title || item.docName || item.name || '')
|
||||
].join('\n');
|
||||
return expectedSources.some((source) => haystack.includes(source.replace(/\s+/g, ' ').trim()));
|
||||
}
|
||||
|
||||
const questions = parseQuestions(fs.readFileSync(file, 'utf8'));
|
||||
if (!questions.length) {
|
||||
console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const token = await login();
|
||||
const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token);
|
||||
const vectorStatus = vector.data?.data;
|
||||
if (!vectorStatus?.matched) {
|
||||
console.error(`FAIL: vector index mismatch: ${vectorStatus?.message || 'unknown'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
let warned = 0;
|
||||
console.log(`VERIFY ${questions.length} question(s), prefix=${prefixes.join(',')}, base=${baseUrl}`);
|
||||
for (const item of questions) {
|
||||
const response = await request('POST', '/api/knowledge/search', {
|
||||
queryText: item.question,
|
||||
category: 'sop',
|
||||
position: '生活顾问'
|
||||
}, token);
|
||||
const data = response.data?.data || {};
|
||||
const docs = data.docs || [];
|
||||
const snippets = data.snippets || [];
|
||||
const ok = response.status === 200 && response.data?.code === 200 && Boolean((data.answer || '').trim()) && (docs.length || snippets.length);
|
||||
const sourceOk = hasExpectedSource(data, item.sources);
|
||||
if (!ok) {
|
||||
failed += 1;
|
||||
console.log(`FAIL ${item.id} ${item.scene}: ${item.question}`);
|
||||
continue;
|
||||
}
|
||||
if (!sourceOk) {
|
||||
warned += 1;
|
||||
console.log(`WARN ${item.id} ${item.scene}: answered, but top evidence missed listed source`);
|
||||
} else {
|
||||
console.log(`OK ${item.id} ${item.scene}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SUMMARY ok=${questions.length - failed}/${questions.length} warnings=${warned} vector=${vectorStatus.embeddedFragments}/${vectorStatus.fragments}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user