R66 A1-A6:小团队试点收敛与上线验收
这个提交包含在:
@@ -41,11 +41,74 @@ FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT
|
||||
export FIELD_ENCRYPTION_KEY
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
REGRESSION_CHANNEL_IDS=()
|
||||
|
||||
track_regression_channel() {
|
||||
local channel_id="$1"
|
||||
REGRESSION_CHANNEL_IDS+=("$channel_id")
|
||||
}
|
||||
|
||||
disable_channels_by_ids() {
|
||||
if [[ "${#REGRESSION_CHANNEL_IDS[@]}" -eq 0 || -z "${DATABASE_URL:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local channel_ids_csv
|
||||
channel_ids_csv="$(IFS=,; printf '%s' "${REGRESSION_CHANNEL_IDS[*]}")"
|
||||
DATABASE_URL="$DATABASE_URL" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' >/dev/null 2>&1 || true
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean);
|
||||
if (!channelIds.length) return;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { id: { in: channelIds } },
|
||||
data: { enabled: false },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch(() => {});
|
||||
NODE
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
disable_channels_by_ids
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
disable_stale_regression_channels() {
|
||||
DATABASE_URL="$DATABASE_URL" node - <<'NODE' >/dev/null 2>&1 || true
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { startsWith: 'IM Mock ' } },
|
||||
{ name: { startsWith: 'IM Failing ' } },
|
||||
{ name: { startsWith: 'IM Secret ' } },
|
||||
{ name: { startsWith: 'IM Legacy ' } },
|
||||
],
|
||||
},
|
||||
data: { enabled: false },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch(() => {});
|
||||
NODE
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
@@ -374,7 +437,7 @@ create_and_submit_purchase() {
|
||||
printf '%s\n' "$purchase_id"
|
||||
}
|
||||
|
||||
query_im_outboxes_for_entity() {
|
||||
query_im_deliveries_for_entity() {
|
||||
local entity_id="$1"
|
||||
local channel_ids_csv="$2"
|
||||
DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE'
|
||||
@@ -386,33 +449,56 @@ const { createPrismaClient } = require('./src/prismaClient');
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const items = await prisma.integrationOutbox.findMany({
|
||||
where: { event_type: 'notification.im' },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 200,
|
||||
const instances = await prisma.workflowInstance.findMany({
|
||||
where: { purchase_request_id: entityId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
last_error: true,
|
||||
payload: true,
|
||||
workflow_tasks: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const taskIds = instances.flatMap((instance) => instance.workflow_tasks.map((task) => task.id));
|
||||
const notifications = taskIds.length
|
||||
? await prisma.notification.findMany({
|
||||
where: {
|
||||
type: 'workflow.task_assigned',
|
||||
target_type: 'workflow_task',
|
||||
target_id: { in: taskIds },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: [];
|
||||
const notificationIds = notifications.map((notification) => notification.id);
|
||||
const deliveries = notificationIds.length
|
||||
? await prisma.notificationDelivery.findMany({
|
||||
where: {
|
||||
notification_id: { in: notificationIds },
|
||||
channel_id: { in: channelIds },
|
||||
},
|
||||
orderBy: [{ channel_id: 'asc' }, { attempt_no: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
notification_id: true,
|
||||
channel_id: true,
|
||||
status: true,
|
||||
attempt_no: true,
|
||||
result_code: true,
|
||||
error_message: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
const payload = item.payload && typeof item.payload === 'object' ? item.payload : null;
|
||||
return payload
|
||||
&& payload.entityId === entityId
|
||||
&& payload.notificationType === 'workflow.task_assigned'
|
||||
&& channelIds.includes(payload.channelId);
|
||||
}).map((item) => ({
|
||||
const items = deliveries.map((item) => ({
|
||||
id: item.id,
|
||||
notificationId: item.notification_id,
|
||||
status: item.status,
|
||||
lastError: item.last_error,
|
||||
channelId: item.payload.channelId,
|
||||
code: item.payload.notificationType,
|
||||
attemptNo: item.attempt_no,
|
||||
resultCode: item.result_code,
|
||||
errorMessage: item.error_message,
|
||||
channelId: item.channel_id,
|
||||
}));
|
||||
|
||||
process.stdout.write(JSON.stringify(filtered));
|
||||
process.stdout.write(JSON.stringify(items));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
@@ -568,7 +654,9 @@ const {
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[1/7] login employee + finance_mgr"
|
||||
disable_stale_regression_channels
|
||||
|
||||
echo "[1/10] login employee + finance_mgr"
|
||||
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")"
|
||||
FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")"
|
||||
|
||||
@@ -579,30 +667,28 @@ SUPPLIER_ID="${FIXTURE_IDS[2]}"
|
||||
DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}"
|
||||
BUDGET_POOL_ID="${FIXTURE_IDS[4]}"
|
||||
|
||||
echo "[2/7] create mock + failing wecom channels"
|
||||
echo "[2/10] create mock + failing wecom channels"
|
||||
MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "" "$TMP_DIR/create-mock-channel.json")"
|
||||
track_regression_channel "$MOCK_CHANNEL_ID"
|
||||
FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "" "$TMP_DIR/create-failing-channel.json")"
|
||||
track_regression_channel "$FAIL_CHANNEL_ID"
|
||||
|
||||
echo "[3/7] submit purchase request and verify IM outbox written"
|
||||
echo "[3/10] submit purchase request and verify IM deliveries"
|
||||
PURCHASE_ID_A="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression A ${RUN_SUFFIX}" "purchase-a" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")"
|
||||
query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a.json"
|
||||
assert_jq "$TMP_DIR/outboxes-a.json" 'length == 2 and all(.[]; .status == "pending")' "submit purchase enqueues two IM outboxes"
|
||||
query_im_deliveries_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-a.json"
|
||||
assert_jq "$TMP_DIR/deliveries-a.json" 'length == 4 and (map(select(.status == "success" and .resultCode == "mock_sent")) | length) == 1 and (map(select(.status == "failed" and .resultCode == "wecom_network_error" and (.errorMessage | type == "string") and (.errorMessage | length > 0))) | length) == 3' "submit purchase writes IM delivery attempts"
|
||||
|
||||
echo "[4/7] run outbox job => mock succeeds, failing webhook does not block business transaction"
|
||||
OUTBOX_IDS_A="$(jq -r "map(.id) | join(\",\")" "$TMP_DIR/outboxes-a.json")"
|
||||
dispatch_outboxes_by_ids "$OUTBOX_IDS_A" "true" > "$TMP_DIR/outbox-job-1.log"
|
||||
query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a-after-job.json"
|
||||
assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(.status) | sort == ["failed","succeeded"]' "outbox success and failure statuses"
|
||||
assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(select(.status == "failed"))[0].lastError | type == "string" and length > 0' "failing webhook captured error"
|
||||
echo "[4/10] verify failing webhook is captured without failing business submission"
|
||||
assert_jq "$TMP_DIR/deliveries-a.json" '(map(select(.status == "failed")) | length) >= 1 and (map(select(.status == "failed"))[0].errorMessage | type == "string")' "failing webhook captured delivery error"
|
||||
|
||||
echo "[5/7] disable channels and verify business submission safely skips IM enqueue"
|
||||
echo "[5/10] disable channels and verify business submission safely skips IM delivery"
|
||||
patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "false" "$TMP_DIR/disable-mock-channel.json"
|
||||
patch_channel_enabled "$FINANCE_MGR_TOKEN" "$FAIL_CHANNEL_ID" "false" "$TMP_DIR/disable-fail-channel.json"
|
||||
PURCHASE_ID_B="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression B ${RUN_SUFFIX}" "purchase-b" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")"
|
||||
query_im_outboxes_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-b.json"
|
||||
assert_jq "$TMP_DIR/outboxes-b.json" 'length == 0' "disabled channels should not enqueue IM outbox"
|
||||
query_im_deliveries_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-b.json"
|
||||
assert_jq "$TMP_DIR/deliveries-b.json" 'length == 0' "disabled channels should not write IM deliveries"
|
||||
|
||||
echo "[6/7] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success"
|
||||
echo "[6/10] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success"
|
||||
patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "true" "$TMP_DIR/re-enable-mock-channel.json"
|
||||
MANUAL_OUTBOX_ID="$(enqueue_manual_im_outbox "$MOCK_CHANNEL_ID")"
|
||||
dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-disabled.log"
|
||||
@@ -616,6 +702,7 @@ assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.te
|
||||
|
||||
echo "[8/10] create encrypted secret channel and verify masked read model"
|
||||
SECRET_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Secret ${RUN_SUFFIX}" "mock" "" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")"
|
||||
track_regression_channel "$SECRET_CHANNEL_ID"
|
||||
assert_jq "$TMP_DIR/create-secret-channel.json" '.data.channel.configJson.secretConfigured == true and (.data.channel.configJson.secretMasked | type == "string") and (.data.channel.configJson | has("secret") | not)' "create channel masks secret on read"
|
||||
secret_test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$SECRET_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/secret-channel-test.json" '{}')"
|
||||
assert_status "$secret_test_status" "200" "test encrypted channel by id" "$TMP_DIR/secret-channel-test.json"
|
||||
@@ -623,6 +710,7 @@ assert_jq "$TMP_DIR/secret-channel-test.json" '.data.testResult.ok == true and .
|
||||
|
||||
echo "[9/10] migrate legacy plain secret channel to encrypted storage and keep test path working"
|
||||
LEGACY_CHANNEL_ID="$(create_legacy_secret_channel "IM Legacy ${RUN_SUFFIX}" "http://127.0.0.1:9/wecom" "legacy-${RUN_SUFFIX}")"
|
||||
track_regression_channel "$LEGACY_CHANNEL_ID"
|
||||
query_channel_secret_state "$LEGACY_CHANNEL_ID" > "$TMP_DIR/legacy-secret-before.json"
|
||||
assert_jq "$TMP_DIR/legacy-secret-before.json" '.hasSecret == true and .hasSecretEncrypted == false' "legacy secret starts as plain text"
|
||||
legacy_test_before_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$LEGACY_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/legacy-channel-test-before.json" '{}')"
|
||||
|
||||
在新工单中引用
屏蔽一个用户