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
这个提交包含在:
2026-02-11 20:33:30 +08:00
父节点 3813d1955e
当前提交 3b221a2202
共修改 44 个文件,包含 6760 行新增和 74 行删除
+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
查看文件
@@ -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"
+47
查看文件
@@ -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"
+47
查看文件
@@ -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"