feat(aihr): harden M0-M2 mobile training flows
This commit is contained in:
@@ -125,6 +125,7 @@ FROM (
|
||||
UNION ALL SELECT 'aihr_practice_audio'
|
||||
UNION ALL SELECT 'aihr_practice_audio_upload'
|
||||
UNION ALL SELECT 'aihr_practice_calibration'
|
||||
UNION ALL SELECT 'aihr_practice_help_event'
|
||||
UNION ALL SELECT 'aihr_knowledge_gap'
|
||||
UNION ALL SELECT 'aihr_knowledge_answer_feedback'
|
||||
UNION ALL SELECT 'aihr_knowledge_space_grant'
|
||||
@@ -155,6 +156,8 @@ FROM (
|
||||
UNION ALL SELECT 'aihr_sop_applicability'
|
||||
UNION ALL SELECT 'aihr_onboard_task'
|
||||
UNION ALL SELECT 'aihr_qualification_gate'
|
||||
UNION ALL SELECT 'aihr_org_directory'
|
||||
UNION ALL SELECT 'aihr_tenant_org_binding'
|
||||
UNION ALL SELECT 'aihr_memory_candidate'
|
||||
UNION ALL SELECT 'aihr_assistant_capture'
|
||||
UNION ALL SELECT 'aihr_assistant_capture_status_log'
|
||||
@@ -165,6 +168,8 @@ FROM (
|
||||
UNION ALL SELECT 'aihr_broadcast_message'
|
||||
UNION ALL SELECT 'aihr_broadcast_read'
|
||||
UNION ALL SELECT 'aihr_broadcast_version'
|
||||
UNION ALL SELECT 'aihr_broadcast_target_rule'
|
||||
UNION ALL SELECT 'aihr_broadcast_target_recipient'
|
||||
UNION ALL SELECT 'aihr_personal_space'
|
||||
UNION ALL SELECT 'aihr_personal_item'
|
||||
UNION ALL SELECT 'aihr_personal_fragment'
|
||||
@@ -394,6 +399,79 @@ REMOTE
|
||||
|
||||
[[ "$broadcast_version_unique_index" = "0|tenant_id,message_id,version|0" ]] \
|
||||
|| fail "remote broadcast-version unique index invalid: expected unique full columns tenant_id,message_id,version; got ${broadcast_version_unique_index:-missing}"
|
||||
local broadcast_targeting_column_contract
|
||||
broadcast_targeting_column_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
db="$1"
|
||||
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
|
||||
SELECT CONCAT(column_name, '|', column_type, '|', is_nullable, '|', COALESCE(column_default, '<NULL>'))
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_broadcast_message'
|
||||
AND column_name IN ('required_read', 'target_payload_hash')
|
||||
ORDER BY FIELD(column_name, 'required_read', 'target_payload_hash');
|
||||
SQL
|
||||
REMOTE
|
||||
)" || fail "remote broadcast M1 column check failed: $remote_ssh:$remote_db"
|
||||
|
||||
[[ "$broadcast_targeting_column_contract" = $'required_read|tinyint(1)|NO|0\ntarget_payload_hash|char(64)|YES|<NULL>' ]] \
|
||||
|| fail "remote broadcast M1 column contract invalid: expected required_read tinyint(1) NOT NULL DEFAULT 0 and nullable target_payload_hash char(64); got ${broadcast_targeting_column_contract:-missing}"
|
||||
local broadcast_target_table_contract
|
||||
broadcast_target_table_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
db="$1"
|
||||
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
|
||||
SELECT CONCAT(table_name, '.', column_name, '|', column_type, '|', is_nullable)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN ('aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient')
|
||||
ORDER BY FIELD(table_name, 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient'), ordinal_position;
|
||||
SQL
|
||||
REMOTE
|
||||
)" || fail "remote broadcast target-table contract check failed: $remote_ssh:$remote_db"
|
||||
|
||||
[[ "$broadcast_target_table_contract" = $'aihr_broadcast_target_rule.id|bigint|NO\naihr_broadcast_target_rule.tenant_id|varchar(20)|NO\naihr_broadcast_target_rule.message_id|bigint|NO\naihr_broadcast_target_rule.target_type|varchar(20)|NO\naihr_broadcast_target_rule.target_value|varchar(100)|NO\naihr_broadcast_target_rule.create_time|datetime|NO\naihr_broadcast_target_recipient.id|bigint|NO\naihr_broadcast_target_recipient.tenant_id|varchar(20)|NO\naihr_broadcast_target_recipient.message_id|bigint|NO\naihr_broadcast_target_recipient.recipient_key|varchar(180)|NO\naihr_broadcast_target_recipient.subject_ref|varchar(100)|NO\naihr_broadcast_target_recipient.user_id|bigint|YES\naihr_broadcast_target_recipient.target_status|varchar(20)|NO\naihr_broadcast_target_recipient.target_reason|varchar(100)|NO\naihr_broadcast_target_recipient.create_time|datetime|NO\naihr_broadcast_target_recipient.update_time|datetime|NO' ]] \
|
||||
|| fail "remote broadcast target-table contract invalid: expected server-written recipient identity, status, reason, and timing fields; got ${broadcast_target_table_contract:-missing}"
|
||||
local broadcast_target_rule_unique_index
|
||||
broadcast_target_rule_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
db="$1"
|
||||
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
|
||||
SELECT CONCAT(
|
||||
MIN(non_unique), '|',
|
||||
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
|
||||
SUM(sub_part IS NOT NULL)
|
||||
)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_broadcast_target_rule'
|
||||
AND index_name = 'uk_aihr_broadcast_target_rule';
|
||||
SQL
|
||||
REMOTE
|
||||
)" || fail "remote broadcast target-rule unique-index check failed: $remote_ssh:$remote_db"
|
||||
|
||||
[[ "$broadcast_target_rule_unique_index" = "0|tenant_id,message_id,target_type,target_value|0" ]] \
|
||||
|| fail "remote broadcast target-rule unique index invalid: expected unique full columns tenant_id,message_id,target_type,target_value; got ${broadcast_target_rule_unique_index:-missing}"
|
||||
local broadcast_target_recipient_unique_index
|
||||
broadcast_target_recipient_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
db="$1"
|
||||
mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL'
|
||||
SELECT CONCAT(
|
||||
MIN(non_unique), '|',
|
||||
COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|',
|
||||
SUM(sub_part IS NOT NULL)
|
||||
)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_broadcast_target_recipient'
|
||||
AND index_name = 'uk_aihr_broadcast_target_recipient';
|
||||
SQL
|
||||
REMOTE
|
||||
)" || fail "remote broadcast target-recipient unique-index check failed: $remote_ssh:$remote_db"
|
||||
|
||||
[[ "$broadcast_target_recipient_unique_index" = "0|tenant_id,message_id,recipient_key|0" ]] \
|
||||
|| fail "remote broadcast target-recipient unique index invalid: expected unique full columns tenant_id,message_id,recipient_key; got ${broadcast_target_recipient_unique_index:-missing}"
|
||||
local missing_work_assistant_columns
|
||||
missing_work_assistant_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
@@ -422,13 +500,17 @@ REMOTE
|
||||
|
||||
[[ -z "$missing_work_assistant_columns" ]] \
|
||||
|| fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')"
|
||||
echo "remote_schema=57/57 $remote_ssh:$remote_db"
|
||||
echo "remote_schema=62/62 $remote_ssh:$remote_db"
|
||||
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
|
||||
echo "remote_knowledge_category_columns=7/7 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_publish_audit_columns=3/3 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_publish_idempotency=3/3 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_read_idempotency=3/3 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_version_uniqueness=3/3 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_targeting_contract=2/2 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_target_table_contract=16/16 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_target_rule_uniqueness=4/4 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_target_recipient_uniqueness=3/3 $remote_ssh:$remote_db"
|
||||
echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db"
|
||||
echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db"
|
||||
echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db"
|
||||
|
||||
@@ -32,6 +32,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260721_work_assistant_results_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql"
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql"
|
||||
|
||||
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
|
||||
|
||||
@@ -124,6 +124,17 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_eviden
|
||||
evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")"
|
||||
test "$evidence_tables" = 'aihr_practice_audio,aihr_practice_audio_upload,aihr_practice_calibration'
|
||||
|
||||
practice_help_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql"
|
||||
test -f "$practice_help_migration"
|
||||
mysql "$DB" < "$practice_help_migration"
|
||||
mysql "$DB" < "$practice_help_migration"
|
||||
practice_help_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_help_event';")"
|
||||
test "$practice_help_columns" = 'id,tenant_id,session_id,scenario_id,ext_party_id,round_index,create_time'
|
||||
practice_help_tenant_collation="$(mysql "$DB" -N -B -e "SELECT CONCAT(character_set_name,'|',collation_name) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_help_event' AND column_name='tenant_id';")"
|
||||
test "$practice_help_tenant_collation" = 'utf8mb4|utf8mb4_general_ci'
|
||||
practice_help_session_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_practice_help_event' AND index_name='idx_aihr_practice_help_session';")"
|
||||
test "$practice_help_session_index" = 'tenant_id,session_id,create_time'
|
||||
|
||||
mysql "$DB" <<'SQL'
|
||||
CREATE TABLE aihr_sop_answer_review (
|
||||
id bigint NOT NULL AUTO_INCREMENT,
|
||||
@@ -312,6 +323,29 @@ test "$broadcast_read_unique_index" = '0|tenant_id,message_id,user_id|0'
|
||||
broadcast_version_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_version' AND index_name='uk_aihr_broadcast_version';")"
|
||||
test "$broadcast_version_unique_index" = '0|tenant_id,message_id,version|0'
|
||||
|
||||
mysql "$DB" <<'SQL'
|
||||
INSERT INTO aihr_broadcast_message (
|
||||
tenant_id, title, content, status, published_by, published_time, create_time, update_time
|
||||
) VALUES ('000000', 'legacy M0 targeting default', 'legacy content', 'PUBLISHED', 1, NOW(), NOW(), NOW());
|
||||
SQL
|
||||
|
||||
broadcast_targeting_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql"
|
||||
test -f "$broadcast_targeting_migration"
|
||||
mysql "$DB" < "$broadcast_targeting_migration"
|
||||
mysql "$DB" < "$broadcast_targeting_migration"
|
||||
broadcast_targeting_column_contract="$(mysql "$DB" -N -B -e "SELECT CONCAT(column_name,'|',column_type,'|',is_nullable,'|',COALESCE(column_default,'<NULL>')) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND column_name IN ('required_read','target_payload_hash') ORDER BY FIELD(column_name,'required_read','target_payload_hash');")"
|
||||
test "$broadcast_targeting_column_contract" = $'required_read|tinyint(1)|NO|0\ntarget_payload_hash|char(64)|YES|<NULL>'
|
||||
broadcast_targeting_legacy_defaults="$(mysql "$DB" -N -B -e "SELECT CONCAT(required_read,'|',COALESCE(target_payload_hash,'NULL')) FROM aihr_broadcast_message WHERE title='legacy M0 targeting default';")"
|
||||
test "$broadcast_targeting_legacy_defaults" = '0|NULL'
|
||||
broadcast_targeting_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_broadcast_target_rule','aihr_broadcast_target_recipient');")"
|
||||
test "$broadcast_targeting_tables" = 'aihr_broadcast_target_recipient,aihr_broadcast_target_rule'
|
||||
broadcast_target_table_contract="$(mysql "$DB" -N -B -e "SELECT CONCAT(table_name,'.',column_name,'|',column_type,'|',is_nullable) FROM information_schema.columns WHERE table_schema='$DB' AND table_name IN ('aihr_broadcast_target_rule','aihr_broadcast_target_recipient') ORDER BY FIELD(table_name,'aihr_broadcast_target_rule','aihr_broadcast_target_recipient'), ordinal_position;")"
|
||||
test "$broadcast_target_table_contract" = $'aihr_broadcast_target_rule.id|bigint|NO\naihr_broadcast_target_rule.tenant_id|varchar(20)|NO\naihr_broadcast_target_rule.message_id|bigint|NO\naihr_broadcast_target_rule.target_type|varchar(20)|NO\naihr_broadcast_target_rule.target_value|varchar(100)|NO\naihr_broadcast_target_rule.create_time|datetime|NO\naihr_broadcast_target_recipient.id|bigint|NO\naihr_broadcast_target_recipient.tenant_id|varchar(20)|NO\naihr_broadcast_target_recipient.message_id|bigint|NO\naihr_broadcast_target_recipient.recipient_key|varchar(180)|NO\naihr_broadcast_target_recipient.subject_ref|varchar(100)|NO\naihr_broadcast_target_recipient.user_id|bigint|YES\naihr_broadcast_target_recipient.target_status|varchar(20)|NO\naihr_broadcast_target_recipient.target_reason|varchar(100)|NO\naihr_broadcast_target_recipient.create_time|datetime|NO\naihr_broadcast_target_recipient.update_time|datetime|NO'
|
||||
broadcast_target_rule_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_target_rule' AND index_name='uk_aihr_broadcast_target_rule';")"
|
||||
test "$broadcast_target_rule_unique_index" = '0|tenant_id,message_id,target_type,target_value|0'
|
||||
broadcast_target_recipient_unique_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_target_recipient' AND index_name='uk_aihr_broadcast_target_recipient';")"
|
||||
test "$broadcast_target_recipient_unique_index" = '0|tenant_id,message_id,recipient_key|0'
|
||||
|
||||
mysql "$DB" -e "ALTER TABLE aihr_broadcast_message DROP INDEX uk_aihr_broadcast_message_publish_request, DROP COLUMN publish_request_hash, DROP COLUMN publish_request_key, DROP COLUMN withdraw_reason;"
|
||||
mysql "$DB" <<'SQL'
|
||||
INSERT INTO aihr_broadcast_message (
|
||||
@@ -349,4 +383,4 @@ fi
|
||||
broadcast_failed_publish_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_broadcast_message' AND index_name='uk_aihr_broadcast_message_publish_request';")"
|
||||
test "$broadcast_failed_publish_index" = '1|tenant_id,publish_request_key,published_by|1'
|
||||
|
||||
echo "PASS: AIHR legacy schema migrations are idempotent, including knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with v1 snapshots, and trusted broadcast question context"
|
||||
echo "PASS: AIHR legacy schema migrations are idempotent, including knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, broadcast publish/withdraw audit with M1 targeting defaults and target-table contracts, v1 snapshots, and trusted broadcast question context"
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* M0 "银城大喇叭" 写入验收。
|
||||
*
|
||||
* 默认仅允许对本机回环地址写入:发布一条唯一测试消息,验证员工端的可见、已读和
|
||||
* 消息追问链路,再撤回该消息。脚本始终要求调用方提供已授权的短期管理员和员工令牌,
|
||||
* 不会读取服务端验证码、密码或会话数据。远端写入还必须同时提供命令行和环境变量的
|
||||
* 明确授权。
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const baseUrl = trimTrailingSlash(args.get('base-url') || process.env.AIHR_BASE_URL || 'https://wygj-api.localhost');
|
||||
const url = new URL(baseUrl);
|
||||
const isLocal = url.hostname === 'localhost'
|
||||
|| url.hostname === '127.0.0.1'
|
||||
|| url.hostname === '::1'
|
||||
|| url.hostname.endsWith('.localhost');
|
||||
const allowRemoteWrite = args.has('allow-remote-write') && process.env.AIHR_M0_ALLOW_REMOTE_WRITE === 'true';
|
||||
const configuredTenantId = String(args.get('tenant-id') || process.env.AIHR_M0_TENANT_ID || '').trim();
|
||||
const reportPath = args.get('report') ? path.resolve(root, args.get('report')) : '';
|
||||
const mobileClientId = process.env.AIHR_M0_EMPLOYEE_CLIENT_ID || '428a8310cd442757ae699df5d894f051';
|
||||
const adminClientId = process.env.AIHR_M0_ADMIN_CLIENT_ID || 'e5cd7e4891bf95d1d19206ce24a7b32e';
|
||||
const prepareLocalQueryFixture = args.has('prepare-local-query-fixture');
|
||||
const runId = `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
const title = `M0 自动验收 ${runId}`;
|
||||
const content = `这是仅用于本机 M0 自动化验收的临时公司消息(${runId})。请以正式通知为准。`;
|
||||
const withdrawReason = `M0 自动化验收清理 ${runId}`;
|
||||
const checks = [];
|
||||
let admin;
|
||||
let employee;
|
||||
let tenantId = '';
|
||||
let messageId;
|
||||
let withdrawn = false;
|
||||
let failure;
|
||||
let cleanupFailure;
|
||||
const queryFixture = {
|
||||
enabled: false,
|
||||
appId: null,
|
||||
knowledgeId: null,
|
||||
appCreated: false,
|
||||
appBindingCreated: false,
|
||||
employeeGrantCreated: false
|
||||
};
|
||||
|
||||
if (url.protocol === 'https:' && url.hostname.endsWith('.localhost') && !process.env.NODE_EXTRA_CA_CERTS) {
|
||||
// ponytail: portless local CA is not visible to Node fetch in every shell session.
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
}
|
||||
|
||||
function parseArgs(values) {
|
||||
return new Map(values.map((value) => {
|
||||
const [key, ...rest] = value.replace(/^--/, '').split('=');
|
||||
return [key, rest.length ? rest.join('=') : 'true'];
|
||||
}));
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return String(value).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function request(method, endpoint, { token, clientId, body } = {}) {
|
||||
const headers = {
|
||||
clientid: clientId || mobileClientId,
|
||||
'Content-Language': 'zh_CN'
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
let requestBody;
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||
requestBody = JSON.stringify(body);
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`${method} ${endpoint} 请求失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`${method} ${endpoint} 返回了无法解析的响应(HTTP ${response.status})`);
|
||||
}
|
||||
return { status: response.status, payload };
|
||||
}
|
||||
|
||||
function businessCode(result) {
|
||||
return Number(result?.payload?.code);
|
||||
}
|
||||
|
||||
function requireSuccess(result, label) {
|
||||
if (result.status < 200 || result.status >= 300 || businessCode(result) !== 200) {
|
||||
const message = String(result.payload?.msg || `HTTP ${result.status}`);
|
||||
throw new Error(`${label}失败:${message}`);
|
||||
}
|
||||
return result.payload.data;
|
||||
}
|
||||
|
||||
function requireUnavailable(result, label) {
|
||||
if (businessCode(result) !== 404) {
|
||||
throw new Error(`${label}应不可用,实际业务码为 ${Number.isFinite(businessCode(result)) ? businessCode(result) : `HTTP ${result.status}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
function record(name, details = {}) {
|
||||
checks.push({ name, status: 'PASS', ...details });
|
||||
console.log(`PASS ${name}`);
|
||||
}
|
||||
|
||||
function requireValue(value, label) {
|
||||
if (value === null || value === undefined || value === '') throw new Error(`${label}缺失`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function docker(container, command) {
|
||||
try {
|
||||
return execFileSync('docker', ['exec', container, ...command], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
throw new Error('本机消息追问临时授权需要可用的 wygj-mysql Docker 容器;也可省略 --prepare-local-query-fixture,直接验证现有授权配置');
|
||||
}
|
||||
}
|
||||
|
||||
function localMysql(sql) {
|
||||
return docker('wygj-mysql', [
|
||||
'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql
|
||||
]);
|
||||
}
|
||||
|
||||
function sqlLiteral(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function prepareQueryFixture() {
|
||||
if (!prepareLocalQueryFixture) return;
|
||||
if (!isLocal) throw new Error('--prepare-local-query-fixture 只允许用于本机环境');
|
||||
if (!tenantId) throw new Error('准备消息追问测试数据前必须先确认租户');
|
||||
queryFixture.enabled = true;
|
||||
const tenant = sqlLiteral(tenantId);
|
||||
const existingApp = localMysql(`select concat(id, '|', status) from aihr_knowledge_app
|
||||
where tenant_id = ${tenant} and auth_type = 'SESSION' and internal_client_key = 'app' limit 1`).trim();
|
||||
const [existingAppIdText, existingAppStatus] = existingApp.split('|');
|
||||
const existingAppId = Number(existingAppIdText);
|
||||
let appId = Number.isSafeInteger(existingAppId) && existingAppId > 0 ? existingAppId : null;
|
||||
if (appId && existingAppStatus !== 'ACTIVE') {
|
||||
throw new Error('本机已有移动端知识应用但未启用;请先启用该应用,或在隔离环境中执行验收');
|
||||
}
|
||||
if (!appId) {
|
||||
const appCode = `m0qa_${runId}`.slice(0, 64);
|
||||
localMysql(`insert into aihr_knowledge_app
|
||||
(tenant_id, app_code, app_name, auth_type, internal_client_key, status, rate_limit_per_minute, create_time, update_time)
|
||||
values (${tenant}, ${sqlLiteral(appCode)}, 'M0 本机验收临时应用', 'SESSION', 'app', 'ACTIVE', 60, now(), now())`);
|
||||
appId = Number(localMysql(`select id from aihr_knowledge_app where tenant_id = ${tenant}
|
||||
and app_code = ${sqlLiteral(appCode)} limit 1`).trim());
|
||||
if (!Number.isSafeInteger(appId) || appId < 1) throw new Error('无法创建本机消息追问临时应用');
|
||||
queryFixture.appCreated = true;
|
||||
}
|
||||
queryFixture.appId = appId;
|
||||
const existingKnowledgeId = Number(localMysql(`select id from aihr_knowledge_info
|
||||
where tenant_id = ${tenant} and status = 'ACTIVE' order by id limit 1`).trim());
|
||||
if (!Number.isSafeInteger(existingKnowledgeId) || existingKnowledgeId < 1) {
|
||||
throw new Error('本机没有启用的知识空间,无法准备消息追问测试数据');
|
||||
}
|
||||
queryFixture.knowledgeId = existingKnowledgeId;
|
||||
const bindingCount = Number(localMysql(`select count(*) from aihr_knowledge_app_space
|
||||
where tenant_id = ${tenant} and app_id = ${appId} and knowledge_id = ${existingKnowledgeId}`).trim());
|
||||
if (bindingCount === 0) {
|
||||
localMysql(`insert into aihr_knowledge_app_space (tenant_id, app_id, knowledge_id, create_time)
|
||||
values (${tenant}, ${appId}, ${existingKnowledgeId}, now())`);
|
||||
queryFixture.appBindingCreated = true;
|
||||
}
|
||||
const grantCount = Number(localMysql(`select count(*) from aihr_knowledge_space_grant
|
||||
where tenant_id = ${tenant} and knowledge_id = ${existingKnowledgeId}
|
||||
and principal_type = 'ROLE' and principal_value = 'employee'
|
||||
and permission in ('READ', 'MANAGE') and status = 'ACTIVE'`).trim());
|
||||
if (grantCount === 0) {
|
||||
localMysql(`insert into aihr_knowledge_space_grant
|
||||
(tenant_id, knowledge_id, principal_type, principal_value, permission, status, create_time, update_time)
|
||||
values (${tenant}, ${existingKnowledgeId}, 'ROLE', 'employee', 'READ', 'ACTIVE', now(), now())`);
|
||||
queryFixture.employeeGrantCreated = true;
|
||||
}
|
||||
record('本机消息追问临时授权已准备');
|
||||
}
|
||||
|
||||
function cleanupQueryFixture() {
|
||||
if (!queryFixture.enabled || !isLocal || !tenantId) return;
|
||||
const tenant = sqlLiteral(tenantId);
|
||||
try {
|
||||
if (queryFixture.employeeGrantCreated && Number.isSafeInteger(queryFixture.knowledgeId)) {
|
||||
localMysql(`delete from aihr_knowledge_space_grant where tenant_id = ${tenant}
|
||||
and knowledge_id = ${queryFixture.knowledgeId} and principal_type = 'ROLE'
|
||||
and principal_value = 'employee' and permission = 'READ'`);
|
||||
}
|
||||
if (queryFixture.appBindingCreated && Number.isSafeInteger(queryFixture.appId) && Number.isSafeInteger(queryFixture.knowledgeId)) {
|
||||
localMysql(`delete from aihr_knowledge_app_space where tenant_id = ${tenant}
|
||||
and app_id = ${queryFixture.appId} and knowledge_id = ${queryFixture.knowledgeId}`);
|
||||
}
|
||||
if (queryFixture.appCreated && Number.isSafeInteger(queryFixture.appId)) {
|
||||
localMysql(`delete from aihr_knowledge_app where tenant_id = ${tenant} and id = ${queryFixture.appId}`);
|
||||
}
|
||||
console.log('CLEANUP 已恢复本机消息追问临时授权');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cleanupFailure ||= new Error(`本机消息追问临时授权清理失败:${message}`);
|
||||
console.error(`CLEANUP_FAILED ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function protectWriteScope() {
|
||||
if (prepareLocalQueryFixture && !isLocal) {
|
||||
throw new Error('--prepare-local-query-fixture 只允许用于本机环境');
|
||||
}
|
||||
if (!isLocal && !allowRemoteWrite) {
|
||||
throw new Error('拒绝对非本机环境写入。若确需在专用测试租户执行,请同时传入 --allow-remote-write 与 AIHR_M0_ALLOW_REMOTE_WRITE=true');
|
||||
}
|
||||
if (!isLocal && !configuredTenantId) throw new Error('非本机验收必须明确传入 --tenant-id=<专用测试租户>');
|
||||
if (!process.env.AIHR_M0_ADMIN_TOKEN || !process.env.AIHR_M0_EMPLOYEE_TOKEN) {
|
||||
throw new Error('M0 验收必须提供 AIHR_M0_ADMIN_TOKEN 和 AIHR_M0_EMPLOYEE_TOKEN;脚本不会读取验证码、密码或会话数据');
|
||||
}
|
||||
}
|
||||
|
||||
function compactResult() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
suite: 'broadcast-m0-acceptance',
|
||||
outcome: failure ? 'FAIL' : 'PASS',
|
||||
baseUrl,
|
||||
runId,
|
||||
tenantId: tenantId || null,
|
||||
messageId: messageId || null,
|
||||
withdrawn,
|
||||
localQueryFixture: queryFixture.enabled ? {
|
||||
prepared: true,
|
||||
appCreated: queryFixture.appCreated,
|
||||
appBindingCreated: queryFixture.appBindingCreated,
|
||||
employeeGrantCreated: queryFixture.employeeGrantCreated
|
||||
} : null,
|
||||
checks,
|
||||
failedAt: failure?.message || null
|
||||
};
|
||||
}
|
||||
|
||||
function writeReport() {
|
||||
if (!reportPath) return;
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(compactResult(), null, 2)}\n`, 'utf8');
|
||||
console.log(`报告已写入 ${reportPath}`);
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
if (!messageId || withdrawn || !admin || !tenantId) return;
|
||||
try {
|
||||
const result = await request('POST', `/api/aihr/broadcast/messages/${messageId}/withdraw`, {
|
||||
token: admin.token,
|
||||
clientId: admin.clientId,
|
||||
body: { reason: withdrawReason, expectedTenantId: tenantId }
|
||||
});
|
||||
requireSuccess(result, '失败后的测试消息撤回');
|
||||
withdrawn = true;
|
||||
console.log('CLEANUP 已撤回临时测试消息');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cleanupFailure ||= new Error(`临时测试消息撤回失败:${message}`);
|
||||
console.error(`CLEANUP_FAILED ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
protectWriteScope();
|
||||
admin = { token: process.env.AIHR_M0_ADMIN_TOKEN, clientId: adminClientId };
|
||||
employee = { token: process.env.AIHR_M0_EMPLOYEE_TOKEN, clientId: mobileClientId };
|
||||
record('受控管理员和员工令牌已提供');
|
||||
|
||||
const adminList = requireSuccess(await request('GET', '/api/aihr/broadcast/admin/messages?pageNum=1&pageSize=1&status=all', {
|
||||
token: admin.token,
|
||||
clientId: admin.clientId
|
||||
}), '读取管理员消息范围');
|
||||
tenantId = String(requireValue(adminList?.tenantId, '管理员生效租户')).trim();
|
||||
if (configuredTenantId && tenantId !== configuredTenantId) {
|
||||
throw new Error(`管理员生效租户与预期不一致:实际 ${tenantId},预期 ${configuredTenantId}`);
|
||||
}
|
||||
record('管理员租户范围已确认', { tenantId });
|
||||
prepareQueryFixture();
|
||||
|
||||
const employeePublish = await request('POST', '/api/aihr/broadcast/messages', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId,
|
||||
body: { requestId: `forbidden-${runId}`, title, content, expectedTenantId: tenantId }
|
||||
});
|
||||
if (employeePublish.status < 400 && businessCode(employeePublish) === 200) {
|
||||
throw new Error('员工令牌意外拥有公司消息发布权限');
|
||||
}
|
||||
record('员工不能发布公司消息');
|
||||
|
||||
const unreadBefore = requireSuccess(await request('GET', '/api/aihr/broadcast/unread-count', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '读取发布前未读数');
|
||||
const unreadBeforeCount = Number(unreadBefore?.unreadCount);
|
||||
if (!Number.isInteger(unreadBeforeCount) || unreadBeforeCount < 0) throw new Error('发布前未读数非法');
|
||||
record('发布前员工未读数可读取');
|
||||
|
||||
const publishRequest = {
|
||||
requestId: `m0-${runId}`,
|
||||
title,
|
||||
content,
|
||||
expectedTenantId: tenantId
|
||||
};
|
||||
const published = requireSuccess(await request('POST', '/api/aihr/broadcast/messages', {
|
||||
token: admin.token,
|
||||
clientId: admin.clientId,
|
||||
body: publishRequest
|
||||
}), '发布测试消息');
|
||||
messageId = Number(requireValue(published?.id, '发布消息编号'));
|
||||
if (!Number.isSafeInteger(messageId) || messageId < 1 || Number(published?.version) !== 1) {
|
||||
throw new Error('发布响应未返回预期的消息编号或 v1 版本');
|
||||
}
|
||||
record('管理员发布消息', { version: 1 });
|
||||
|
||||
const replay = requireSuccess(await request('POST', '/api/aihr/broadcast/messages', {
|
||||
token: admin.token,
|
||||
clientId: admin.clientId,
|
||||
body: publishRequest
|
||||
}), '重放同一发布请求');
|
||||
if (Number(replay?.id) !== messageId || Number(replay?.version) !== 1) {
|
||||
throw new Error('相同 requestId 未返回原消息,发布幂等性失效');
|
||||
}
|
||||
record('发布重放保持幂等');
|
||||
|
||||
const employeeList = requireSuccess(await request('GET', '/api/aihr/broadcast/messages?pageNum=1&pageSize=100', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '读取员工消息列表');
|
||||
const listed = Array.isArray(employeeList?.rows) ? employeeList.rows.find((row) => Number(row?.id) === messageId) : null;
|
||||
if (!listed || listed.read !== false || listed.title !== title) throw new Error('新发布消息未以未读状态出现在员工列表');
|
||||
record('员工列表可见新消息且为未读');
|
||||
|
||||
const detailBeforeRead = requireSuccess(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '读取员工消息详情');
|
||||
if (detailBeforeRead?.title !== title || detailBeforeRead?.content !== content || detailBeforeRead?.read !== false) {
|
||||
throw new Error('员工详情与发布内容或未读状态不一致');
|
||||
}
|
||||
record('员工详情可读取');
|
||||
|
||||
requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/read`, {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '员工首次标记已读');
|
||||
requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/read`, {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '员工重复标记已读');
|
||||
const detailAfterRead = requireSuccess(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '复核员工已读状态');
|
||||
if (detailAfterRead?.read !== true) throw new Error('员工首次已读记录未生效');
|
||||
const unreadAfterRead = requireSuccess(await request('GET', '/api/aihr/broadcast/unread-count', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '读取已读后的未读数');
|
||||
if (Number(unreadAfterRead?.unreadCount) !== unreadBeforeCount) {
|
||||
throw new Error('重复已读改变了未读数,已读幂等性失效');
|
||||
}
|
||||
record('员工已读审计保持幂等');
|
||||
|
||||
const question = requireSuccess(await request('POST', '/api/knowledge/query', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId,
|
||||
body: {
|
||||
queryText: '请概述这条消息的要求,不要把它解释成已向我个人分配任务。',
|
||||
category: 'sop',
|
||||
position: '客服管家',
|
||||
source: 'm0_broadcast_acceptance',
|
||||
limit: 5,
|
||||
broadcastMessageId: messageId
|
||||
}
|
||||
}), '围绕公司消息追问');
|
||||
const questionJson = JSON.stringify(question);
|
||||
const hasBroadcastCitation = Array.isArray(question?.citations)
|
||||
&& question.citations.some((citation) => citation?.sourceType === 'BCAST');
|
||||
if (Number(question?.broadcastContext?.messageId) !== messageId || !hasBroadcastCitation || questionJson.includes(content)) {
|
||||
throw new Error('消息追问未保留可信上下文、未给出 BCAST 引用,或意外序列化了消息正文');
|
||||
}
|
||||
record('员工可围绕消息追问且不泄露正文');
|
||||
|
||||
requireSuccess(await request('POST', `/api/aihr/broadcast/messages/${messageId}/withdraw`, {
|
||||
token: admin.token,
|
||||
clientId: admin.clientId,
|
||||
body: { reason: withdrawReason, expectedTenantId: tenantId }
|
||||
}), '撤回测试消息');
|
||||
withdrawn = true;
|
||||
|
||||
const withdrawnList = requireSuccess(await request('GET', '/api/aihr/broadcast/admin/messages?pageNum=1&pageSize=100&status=WITHDRAWN', {
|
||||
token: admin.token,
|
||||
clientId: admin.clientId
|
||||
}), '读取撤回审计');
|
||||
const withdrawnRow = Array.isArray(withdrawnList?.rows)
|
||||
? withdrawnList.rows.find((row) => Number(row?.id) === messageId)
|
||||
: null;
|
||||
if (!withdrawnRow || withdrawnRow.status !== 'WITHDRAWN' || withdrawnRow.withdrawReason !== withdrawReason) {
|
||||
throw new Error('撤回记录或首次撤回原因未保留');
|
||||
}
|
||||
record('撤回保留审计记录');
|
||||
|
||||
const employeeListAfterWithdraw = requireSuccess(await request('GET', '/api/aihr/broadcast/messages?pageNum=1&pageSize=100', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '撤回后读取员工消息列表');
|
||||
if (Array.isArray(employeeListAfterWithdraw?.rows)
|
||||
&& employeeListAfterWithdraw.rows.some((row) => Number(row?.id) === messageId)) {
|
||||
throw new Error('已撤回消息仍出现在员工列表');
|
||||
}
|
||||
requireUnavailable(await request('GET', `/api/aihr/broadcast/messages/${messageId}`, {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId
|
||||
}), '撤回后员工详情');
|
||||
requireUnavailable(await request('POST', '/api/knowledge/query', {
|
||||
token: employee.token,
|
||||
clientId: employee.clientId,
|
||||
body: {
|
||||
queryText: '这条消息还有效吗?',
|
||||
category: 'sop',
|
||||
position: '客服管家',
|
||||
source: 'm0_broadcast_acceptance',
|
||||
limit: 5,
|
||||
broadcastMessageId: messageId
|
||||
}
|
||||
}), '撤回后消息追问');
|
||||
record('撤回后员工不可见且不可追问');
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
failure = error instanceof Error ? error : new Error(String(error));
|
||||
} finally {
|
||||
await cleanup();
|
||||
cleanupQueryFixture();
|
||||
if (!failure && cleanupFailure) failure = cleanupFailure;
|
||||
writeReport();
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
console.error(`M0_BROADCAST_ACCEPTANCE FAIL ${failure.message}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`M0_BROADCAST_ACCEPTANCE PASS ${checks.length}/${checks.length}`);
|
||||
}
|
||||
Reference in New Issue
Block a user