R66 A1-A6:小团队试点收敛与上线验收

这个提交包含在:
2026-04-26 13:24:24 +08:00
父节点 8353efcbd7
当前提交 a15bbcbadb
共修改 19 个文件,包含 462 行新增和 56 行删除
+123 -35
查看文件
@@ -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" '{}')"