feat(P11-P12): 落地治理开关与移动端MVP(G0-G6/H1/M1-M4)
- 新增 Feature Flag 模型、评估服务与管理 API(erp_sync/invoice_verification)\n- 完成 ERP 与验真灰度收口(路由/服务/作业统一判定)\n- 补齐 pay 与手动 sync 的 department_id 灰度上下文\n- 增加管理后台开关页、PWA 基础设施与移动拍照/审批页面\n- 增加回归脚本、安全基线与压测脚本,更新文档与执行合同\n\nIssue: G0,G1,G2,G3,G4,G5,G6,H1,M1,M2,M3,M4
这个提交包含在:
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-dashboard}"
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-FinanceMgrPassword1234}"
|
||||
ITERATIONS="${ITERATIONS:-10}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
login() {
|
||||
local out_file="$1"
|
||||
local status
|
||||
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
||||
-d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")"
|
||||
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "[ERROR] login failed: HTTP $status" >&2
|
||||
cat "$out_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
benchmark_endpoint() {
|
||||
local endpoint="$1"
|
||||
local token="$2"
|
||||
|
||||
local total_ms=0
|
||||
local max_ms=0
|
||||
local min_ms=999999
|
||||
|
||||
for _ in $(seq 1 "$ITERATIONS"); do
|
||||
local body_file="$TMP_DIR/$(basename "$endpoint").json"
|
||||
local elapsed
|
||||
elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \
|
||||
-H "authorization: Bearer $token" \
|
||||
"$BASE_URL$endpoint")"
|
||||
|
||||
local elapsed_ms
|
||||
elapsed_ms="$(python3 - <<PY
|
||||
value = float("$elapsed")
|
||||
print(int(round(value * 1000)))
|
||||
PY
|
||||
)"
|
||||
|
||||
total_ms=$((total_ms + elapsed_ms))
|
||||
if (( elapsed_ms > max_ms )); then
|
||||
max_ms=$elapsed_ms
|
||||
fi
|
||||
if (( elapsed_ms < min_ms )); then
|
||||
min_ms=$elapsed_ms
|
||||
fi
|
||||
done
|
||||
|
||||
local avg_ms=$((total_ms / ITERATIONS))
|
||||
echo "$endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS"
|
||||
}
|
||||
|
||||
echo "[1/2] Login"
|
||||
TOKEN="$(login "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[2/2] Dashboard endpoint benchmarks"
|
||||
benchmark_endpoint "/api/dashboard/expense-overview" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/trends" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/top-categories" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/approval-efficiency" "$TOKEN"
|
||||
|
||||
echo "[OK] Dashboard benchmark completed"
|
||||
@@ -10,6 +10,7 @@ RUN_VERIFICATION_REGRESSION="${RUN_VERIFICATION_REGRESSION:-0}"
|
||||
RUN_DASHBOARD_REGRESSION="${RUN_DASHBOARD_REGRESSION:-0}"
|
||||
RUN_BUDGET_REGRESSION="${RUN_BUDGET_REGRESSION:-0}"
|
||||
RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}"
|
||||
RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}"
|
||||
|
||||
echo "=== [1/7] Smoke Test ==="
|
||||
"$SCRIPT_DIR/smoke-test.sh"
|
||||
@@ -95,4 +96,13 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_FEATURE_FLAGS_REGRESSION" == "1" ]]; then
|
||||
echo "=== [12/12] Feature Flags Regression ==="
|
||||
"$SCRIPT_DIR/regression-feature-flags.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [12/12] Feature Flags Regression (skipped, RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "[OK] All regressions passed"
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-feature-flags}"
|
||||
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
local body_file="${4:-}"
|
||||
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
if [[ -n "$body_file" && -f "$body_file" ]]; then
|
||||
cat "$body_file" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local out_file="$3"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
||||
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
||||
assert_status "$status" "200" "login" "$out_file"
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
api_json() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local token="$3"
|
||||
local out_file="$4"
|
||||
local body="${5:-}"
|
||||
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token" \
|
||||
-H "content-type: application/json" \
|
||||
-d "$body"
|
||||
else
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/4] Login finance_mgr"
|
||||
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[2/4] List feature flags"
|
||||
status="$(api_json GET "$BASE_URL/api/system/feature-flags" "$TOKEN" "$TMP_DIR/list.json")"
|
||||
assert_status "$status" "200" "feature-flags list" "$TMP_DIR/list.json"
|
||||
jq -e '.data.items | length >= 2' "$TMP_DIR/list.json" >/dev/null
|
||||
|
||||
ERP_VERSION="$(jq -er '.data.items[] | select(.flagKey=="erp_sync") | .version' "$TMP_DIR/list.json")"
|
||||
|
||||
echo "[3/4] Update erp_sync to force_off/global"
|
||||
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/update.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"regression-force-off\",\"expectedVersion\":$ERP_VERSION}")"
|
||||
assert_status "$status" "200" "feature-flags update" "$TMP_DIR/update.json"
|
||||
jq -e '.data.item.flagKey == "erp_sync"' "$TMP_DIR/update.json" >/dev/null
|
||||
jq -e '.data.item.overrideMode == "force_off"' "$TMP_DIR/update.json" >/dev/null
|
||||
|
||||
echo "[4/4] Verify stale version conflict"
|
||||
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/conflict.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"stale-version\",\"expectedVersion\":$ERP_VERSION}")"
|
||||
if [[ "$status" != "409" ]]; then
|
||||
echo "[ERROR] expected version conflict 409 but got $status" >&2
|
||||
cat "$TMP_DIR/conflict.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] Feature flags regression passed"
|
||||
@@ -292,6 +292,50 @@ run_outbox_once() {
|
||||
npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log"
|
||||
}
|
||||
|
||||
set_erp_flag() {
|
||||
local mode="$1"
|
||||
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("./src/prismaClient");
|
||||
|
||||
(async () => {
|
||||
const mode = process.env.MODE;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "erp_sync" },
|
||||
create: {
|
||||
flag_key: "erp_sync",
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-integrations",
|
||||
},
|
||||
update: {
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-integrations",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[0/6] Ensure ERP feature flag force_on"
|
||||
set_erp_flag "force_on"
|
||||
|
||||
assert_mode_semantics() {
|
||||
local mode="$1"
|
||||
local state_json="$2"
|
||||
@@ -373,3 +417,6 @@ run_mode_case "timeout"
|
||||
|
||||
echo "[6/6] Done"
|
||||
echo "[OK] Integrations regression passed"
|
||||
|
||||
echo "[post] Reset ERP feature flag force_off"
|
||||
set_erp_flag "force_off"
|
||||
|
||||
@@ -153,6 +153,50 @@ const { getConfig } = require('./src/config');
|
||||
NODE
|
||||
}
|
||||
|
||||
set_verification_flag() {
|
||||
local mode="$1"
|
||||
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("./src/prismaClient");
|
||||
|
||||
(async () => {
|
||||
const mode = process.env.MODE;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "invoice_verification" },
|
||||
create: {
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-verification",
|
||||
},
|
||||
update: {
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-verification",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[0/6] Ensure verification feature flag force_on"
|
||||
set_verification_flag "force_on"
|
||||
|
||||
echo "[1/6] Login finance_mgr"
|
||||
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
|
||||
|
||||
@@ -184,3 +228,6 @@ if [[ "$status" != "409" ]]; then
|
||||
fi
|
||||
|
||||
echo "[OK] Verification regression passed"
|
||||
|
||||
echo "[post] Reset verification feature flag force_off"
|
||||
set_verification_flag "force_off"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[1/3] Check critical env defaults"
|
||||
if [[ "${JWT_SECRET:-}" == "change_me" ]]; then
|
||||
echo "[WARN] JWT_SECRET should not be change_me in non-dev environments"
|
||||
fi
|
||||
|
||||
if [[ "${ERP_SYNC_ENABLED:-false}" != "false" ]]; then
|
||||
echo "[WARN] ERP_SYNC_ENABLED is not false; ensure feature-flag governance is enabled"
|
||||
fi
|
||||
|
||||
if [[ "${INVOICE_VERIFICATION_ENABLED:-false}" != "false" ]]; then
|
||||
echo "[WARN] INVOICE_VERIFICATION_ENABLED is not false; ensure rollout is controlled"
|
||||
fi
|
||||
|
||||
echo "[2/3] Basic static hardening checks"
|
||||
node --check src/routes/auth.js
|
||||
node --check src/routes/integrations.js
|
||||
node --check src/routes/invoicePool/verification.js
|
||||
node --check src/services/featureFlags/index.js
|
||||
|
||||
echo "[3/3] Dependency audit (non-blocking)"
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm audit --omit=dev --audit-level=high || true
|
||||
fi
|
||||
|
||||
echo "[OK] Security baseline script completed"
|
||||
在新工单中引用
屏蔽一个用户