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 行删除
+4316 -1
查看文件
文件差异内容过多而无法显示 加载差异
+2 -1
查看文件
@@ -32,6 +32,7 @@
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.5",
"vite": "^5.1.0"
"vite": "^5.1.0",
"vite-plugin-pwa": "^0.20.5"
}
}
+27
查看文件
@@ -0,0 +1,27 @@
{
"name": "hyb-oa 移动工作台",
"short_name": "hyb-oa",
"description": "海音达工作台移动端入口,支持离线拍照上传与移动审批。",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/pwa-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 68 B

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 68 B

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 68 B

+47
查看文件
@@ -0,0 +1,47 @@
import { apiRequestData } from "./http";
export type FeatureFlagKey = "erp_sync" | "invoice_verification";
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
export type FeatureFlagRolloutType = "global" | "department_allowlist";
export type FeatureFlagItem = {
id: string | null;
flagKey: FeatureFlagKey;
overrideMode: FeatureFlagOverrideMode;
rolloutType: FeatureFlagRolloutType;
departmentIds: string[];
note: string;
version: number;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
envEnabled: boolean;
effectiveEnabled: boolean;
effectiveReason: string;
matchedDepartmentId: string | null;
requiresDepartment: boolean;
};
export type FeatureFlagListResponse = {
items: FeatureFlagItem[];
};
export type FeatureFlagUpdatePayload = {
overrideMode: FeatureFlagOverrideMode;
rolloutType: FeatureFlagRolloutType;
departmentIds: string[];
note: string;
expectedVersion: number;
};
export async function fetchFeatureFlags() {
return apiRequestData<FeatureFlagListResponse>("/api/system/feature-flags");
}
export async function updateFeatureFlag(flagKey: FeatureFlagKey, payload: FeatureFlagUpdatePayload) {
return apiRequestData<{ item: FeatureFlagItem }>(`/api/system/feature-flags/${flagKey}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
@@ -26,6 +26,16 @@ export const NAV_ITEMS: NavItem[] = [
label: "票据中心",
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
},
{
to: "/mobile/capture",
label: "移动拍照",
anyOf: ["invoice.upload", "invoice.manage"],
},
{
to: "/mobile/approvals",
label: "移动审批",
anyOf: ["expense.approve"],
},
{
to: "/budgets",
label: "预算管理",
@@ -56,6 +66,11 @@ export const NAV_ITEMS: NavItem[] = [
label: "审批规则",
anyOf: ["system.settings"],
},
{
to: "/system/feature-flags",
label: "功能开关",
anyOf: ["system.settings"],
},
{ to: "/settings/profile", label: "个人设置" },
];
+3
查看文件
@@ -5,8 +5,11 @@ import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { queryClient } from "./query/queryClient";
import { AppRouter } from "./router";
import { registerPwaIfEnabled } from "./pwa";
import "./index.css";
registerPwaIfEnabled();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
+126
查看文件
@@ -0,0 +1,126 @@
export type MobileUploadQueueItem = {
id: string;
fileName: string;
mimeType: string;
createdAt: string;
dataUrl: string;
ownerEmployeeId: string | null;
};
const DB_NAME = "hyb-oa-mobile";
const DB_VERSION = 1;
const STORE_NAME = "invoice_upload_queue";
let dbPromise: Promise<IDBDatabase> | null = null;
function getDatabase() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
}
return dbPromise;
}
function toDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("file_reader_invalid_result"));
}
};
reader.onerror = () => reject(reader.error ?? new Error("file_reader_failed"));
reader.readAsDataURL(file);
});
}
function fromDataUrl(dataUrl: string, fallbackFileName: string, fallbackMimeType = "application/octet-stream") {
const match = dataUrl.match(/^data:(.*?);base64,(.*)$/);
if (!match) {
throw new Error("invalid_data_url");
}
const mimeType = match[1] || fallbackMimeType;
const base64 = match[2];
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fallbackFileName, { type: mimeType });
}
async function withStore<T>(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise<T>) {
const db = await getDatabase();
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
executor(store)
.then((result) => {
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed"));
})
.catch((error) => reject(error));
});
}
function requestPromise<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed"));
});
}
export async function enqueueInvoiceUpload(file: File, ownerEmployeeId: string | null = null) {
const dataUrl = await toDataUrl(file);
const item: MobileUploadQueueItem = {
id: crypto.randomUUID(),
fileName: file.name,
mimeType: file.type || "application/octet-stream",
createdAt: new Date().toISOString(),
dataUrl,
ownerEmployeeId,
};
await withStore("readwrite", async (store) => {
await requestPromise(store.put(item));
return true;
});
return item;
}
export async function listQueuedInvoiceUploads() {
return withStore("readonly", async (store) => {
const records = (await requestPromise(store.getAll())) as MobileUploadQueueItem[];
return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
});
}
export async function removeQueuedInvoiceUpload(id: string) {
await withStore("readwrite", async (store) => {
await requestPromise(store.delete(id));
return true;
});
}
export function queuedItemToFile(item: MobileUploadQueueItem) {
return fromDataUrl(item.dataUrl, item.fileName, item.mimeType);
}
+351
查看文件
@@ -0,0 +1,351 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchDepartments } from "../api/departments";
import {
fetchFeatureFlags,
updateFeatureFlag,
type FeatureFlagItem,
type FeatureFlagOverrideMode,
type FeatureFlagRolloutType,
} from "../api/featureFlags";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
const OVERRIDE_MODE_OPTIONS: Array<{ value: FeatureFlagOverrideMode; label: string }> = [
{ value: "force_off", label: "强制关闭" },
{ value: "inherit", label: "继承Env" },
{ value: "force_on", label: "强制开启" },
];
const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string }> = [
{ value: "global", label: "全量" },
{ value: "department_allowlist", label: "部门白名单" },
];
function toDisplayName(flagKey: string) {
if (flagKey === "erp_sync") return "ERP 同步";
if (flagKey === "invoice_verification") return "发票验真";
return flagKey;
}
function dedupeIds(items: string[]) {
return Array.from(new Set(items.filter(Boolean)));
}
type DraftState = {
overrideMode: FeatureFlagOverrideMode;
rolloutType: FeatureFlagRolloutType;
departmentIds: string[];
note: string;
version: number;
};
function buildDraft(item: FeatureFlagItem): DraftState {
return {
overrideMode: item.overrideMode,
rolloutType: item.rolloutType,
departmentIds: item.departmentIds,
note: item.note ?? "",
version: item.version,
};
}
export function FeatureFlagsPage() {
const queryClient = useQueryClient();
const [error, setError] = useState("");
const [drafts, setDrafts] = useState<Record<string, DraftState>>({});
const flagsQuery = useQuery({
queryKey: ["feature-flags"],
queryFn: () => fetchFeatureFlags(),
});
const departmentsQuery = useQuery({
queryKey: ["departments", "feature-flags", "active"],
queryFn: () => fetchDepartments({ includeArchived: false }),
});
const updateMutation = useMutation({
mutationFn: async (flagKey: string) => {
const draft = drafts[flagKey];
if (!draft) {
throw new Error("未找到待提交草稿");
}
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification", {
overrideMode: draft.overrideMode,
rolloutType: draft.rolloutType,
departmentIds:
draft.rolloutType === "department_allowlist" ? dedupeIds(draft.departmentIds) : [],
note: draft.note.trim(),
expectedVersion: draft.version,
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "更新开关失败");
},
});
const departmentsById = useMemo(() => {
const map = new Map<string, string>();
for (const item of departmentsQuery.data?.items ?? []) {
map.set(item.id, item.name);
}
return map;
}, [departmentsQuery.data?.items]);
const items = flagsQuery.data?.items ?? [];
const ensureDraft = (item: FeatureFlagItem) => {
if (drafts[item.flagKey]) {
return drafts[item.flagKey];
}
return buildDraft(item);
};
const patchDraft = (flagKey: string, updater: (prev: DraftState) => DraftState) => {
setDrafts((prev) => {
const base = prev[flagKey];
const next = updater(base ?? buildDraft(items.find((item) => item.flagKey === flagKey)!));
return {
...prev,
[flagKey]: next,
};
});
};
return (
<div className="space-y-6">
<PageHeader
title="功能开关"
subtitle="统一治理 ERP 与验真灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
/>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<Card className="space-y-4 p-6">
<h3 className="text-base font-semibold text-slate-900">开关列表</h3>
{flagsQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{flagsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(flagsQuery.error)
? `${flagsQuery.error.code}: ${mapApiErrorMessage(
flagsQuery.error.code,
flagsQuery.error.details,
flagsQuery.error.message,
)}`
: "加载功能开关失败"}
</p>
) : null}
{!flagsQuery.isLoading && !flagsQuery.isError ? (
<div className="rounded-lg border border-slate-200">
<Table data-testid="feature-flags-table">
<TableHeader>
<tr>
<TableHead>功能</TableHead>
<TableHead>当前状态</TableHead>
<TableHead>覆盖策略</TableHead>
<TableHead>灰度范围</TableHead>
<TableHead>备注</TableHead>
<TableHead className="w-40">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{items.map((item) => {
const draft = ensureDraft(item);
const isPending = updateMutation.isPending && updateMutation.variables === item.flagKey;
return (
<TableRow key={item.flagKey} data-testid={`feature-flag-row-${item.flagKey}`}>
<TableCell>
<div className="font-medium text-slate-900">{toDisplayName(item.flagKey)}</div>
<div className="font-mono text-xs text-slate-500">{item.flagKey}</div>
</TableCell>
<TableCell className="text-sm">
<div>
Env:
<span className={item.envEnabled ? "text-emerald-600" : "text-red-600"}>
{item.envEnabled ? "ON" : "OFF"}
</span>
</div>
<div>
Effective:
<span className={item.effectiveEnabled ? "text-emerald-600" : "text-red-600"}>
{item.effectiveEnabled ? "ON" : "OFF"}
</span>
</div>
<div className="text-xs text-slate-500">reason: {item.effectiveReason}</div>
</TableCell>
<TableCell className="space-y-2 text-sm">
<div>
<Label className="mb-1 block text-xs text-slate-500">override</Label>
<select
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value={draft.overrideMode}
onChange={(event) =>
patchDraft(item.flagKey, (prev) => ({
...prev,
overrideMode: event.target.value as FeatureFlagOverrideMode,
}))
}
>
{OVERRIDE_MODE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<Label className="mb-1 block text-xs text-slate-500">rollout</Label>
<select
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value={draft.rolloutType}
onChange={(event) =>
patchDraft(item.flagKey, (prev) => ({
...prev,
rolloutType: event.target.value as FeatureFlagRolloutType,
departmentIds:
event.target.value === "department_allowlist"
? prev.departmentIds
: [],
}))
}
>
{ROLLOUT_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</TableCell>
<TableCell className="text-sm">
{draft.rolloutType === "department_allowlist" ? (
<div className="space-y-2">
<select
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value=""
onChange={(event) => {
const selected = event.target.value;
if (!selected) return;
patchDraft(item.flagKey, (prev) => ({
...prev,
departmentIds: dedupeIds([...prev.departmentIds, selected]),
}));
}}
>
<option value="">添加部门</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
<div className="max-h-24 overflow-auto rounded border border-slate-200 p-2 text-xs">
{draft.departmentIds.length ? (
draft.departmentIds.map((departmentId) => (
<div
key={departmentId}
className="mb-1 flex items-center justify-between rounded bg-slate-50 px-2 py-1"
>
<span>{departmentsById.get(departmentId) ?? departmentId}</span>
<button
className="text-red-600"
type="button"
onClick={() =>
patchDraft(item.flagKey, (prev) => ({
...prev,
departmentIds: prev.departmentIds.filter((id) => id !== departmentId),
}))
}
>
移除
</button>
</div>
))
) : (
<p className="text-slate-500">暂无白名单部门</p>
)}
</div>
</div>
) : (
<span className="text-slate-500">全量</span>
)}
</TableCell>
<TableCell>
<textarea
className="min-h-24 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
placeholder="填写本次变更说明"
value={draft.note}
onChange={(event) =>
patchDraft(item.flagKey, (prev) => ({
...prev,
note: event.target.value,
}))
}
/>
<p className="mt-1 text-xs text-slate-500">version: {draft.version}</p>
</TableCell>
<TableCell>
<div className="space-y-2">
<Button
data-testid={`feature-flag-save-${item.flagKey}`}
disabled={isPending}
onClick={() => updateMutation.mutate(item.flagKey)}
type="button"
>
{isPending ? "保存中..." : "保存"}
</Button>
<Button
data-testid={`feature-flag-reset-${item.flagKey}`}
variant="secondary"
type="button"
onClick={() =>
setDrafts((prev) => ({
...prev,
[item.flagKey]: buildDraft(item),
}))
}
>
重置
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : null}
</Card>
</div>
);
}
+235
查看文件
@@ -0,0 +1,235 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
fetchWorkflowTaskDetail,
fetchWorkflowTasks,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
} from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Textarea } from "../components/ui/textarea";
type MobileAction = "approve" | "reject" | "return" | "transfer";
export function MobileApprovalsPage() {
const queryClient = useQueryClient();
const [selectedTaskId, setSelectedTaskId] = useState("");
const [error, setError] = useState("");
const [comment, setComment] = useState("");
const [transferToApproverId, setTransferToApproverId] = useState("");
const [transferReason, setTransferReason] = useState("");
const tasksQuery = useQuery({
queryKey: ["mobile", "workflow", "tasks"],
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 30 }),
});
const detailQuery = useQuery({
enabled: Boolean(selectedTaskId),
queryKey: ["mobile", "workflow", "task-detail", selectedTaskId],
queryFn: () => fetchWorkflowTaskDetail(selectedTaskId),
});
const approversQuery = useQuery({
enabled: detailQuery.data?.allowedActions?.includes("transfer") ?? false,
queryKey: ["mobile", "workflow", "approvers"],
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }),
});
const actionMutation = useMutation({
mutationFn: async (action: MobileAction) => {
if (!selectedTaskId) {
throw new Error("请选择任务");
}
if (action === "approve") {
return approveWorkflowTask(selectedTaskId);
}
if (action === "reject") {
if (!comment.trim()) {
throw new Error("驳回必须填写意见");
}
return rejectWorkflowTask(selectedTaskId, comment.trim());
}
if (action === "return") {
if (!comment.trim()) {
throw new Error("退回必须填写意见");
}
return returnWorkflowTask(selectedTaskId, comment.trim());
}
if (!transferToApproverId) {
throw new Error("请选择转审目标审批人");
}
if (transferReason.trim().length < 10) {
throw new Error("转审理由至少 10 个字");
}
return transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim());
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }),
]);
setComment("");
setTransferReason("");
setTransferToApproverId("");
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "动作执行失败");
},
});
const pendingTasks = tasksQuery.data?.items ?? [];
const selectedTask = detailQuery.data;
return (
<div className="space-y-4">
<PageHeader title="移动审批" subtitle="小屏快速处理待办任务。" />
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<Card className="space-y-3 p-4" data-testid="mobile-approvals-list">
<h3 className="text-base font-semibold text-slate-900">待办任务</h3>
{tasksQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{!tasksQuery.isLoading && pendingTasks.length === 0 ? (
<p className="text-sm text-slate-500">暂无待办任务。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<button
key={task.id}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
selectedTaskId === task.id
? "border-primary bg-primary-50 text-primary"
: "border-slate-200 bg-white text-slate-800"
}`}
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => setSelectedTaskId(task.id)}
type="button"
>
<div className="font-medium">{task.stepName}</div>
<div className="text-xs text-slate-500">任务ID: {task.id}</div>
<div className="text-xs text-slate-500">状态: {task.status}</div>
</button>
))}
</div>
</Card>
<Card className="space-y-3 p-4" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-slate-900">任务详情与动作</h3>
{!selectedTaskId ? <p className="text-sm text-slate-500">请先选择任务。</p> : null}
{selectedTaskId && detailQuery.isLoading ? (
<p className="text-sm text-slate-500">加载任务详情中...</p>
) : null}
{selectedTask && (
<>
<div className="rounded-lg border border-slate-200 p-3 text-sm">
<div>任务状态:{selectedTask.task.status}</div>
<div>步骤:{selectedTask.task.stepName}</div>
<div>报销单号:{selectedTask.reimbursement?.formNumber ?? "-"}</div>
<div>金额:{selectedTask.reimbursement?.totalAmount ?? "-"}</div>
<div>提交人:{selectedTask.reimbursement?.submitter?.name ?? "-"}</div>
</div>
<Textarea
data-testid="mobile-approvals-comment"
onChange={(event) => setComment(event.target.value)}
placeholder="驳回/退回意见"
rows={3}
value={comment}
/>
<div className="flex flex-wrap gap-2">
<Button
data-testid="mobile-approvals-approve"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("approve")}
onClick={() => actionMutation.mutate("approve")}
type="button"
>
通过
</Button>
<Button
data-testid="mobile-approvals-reject"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("reject")}
onClick={() => actionMutation.mutate("reject")}
type="button"
variant="secondary"
>
驳回
</Button>
<Button
data-testid="mobile-approvals-return"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("return")}
onClick={() => actionMutation.mutate("return")}
type="button"
variant="secondary"
>
退回
</Button>
</div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3 text-sm">
<p className="font-medium text-slate-800">转审</p>
<select
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="mobile-approvals-transfer-to"
onChange={(event) => setTransferToApproverId(event.target.value)}
value={transferToApproverId}
>
<option value="">请选择目标审批人</option>
{(approversQuery.data?.items ?? []).map((approver) => (
<option key={approver.id} value={approver.id}>
{approver.name} ({approver.employeeNo})
</option>
))}
</select>
<Textarea
data-testid="mobile-approvals-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
placeholder="转审理由(至少10字)"
rows={2}
value={transferReason}
/>
<Button
data-testid="mobile-approvals-transfer"
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
onClick={() => actionMutation.mutate("transfer")}
type="button"
variant="secondary"
>
提交转审
</Button>
</div>
</>
)}
</Card>
</div>
);
}
+242
查看文件
@@ -0,0 +1,242 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import {
enqueueInvoiceUpload,
listQueuedInvoiceUploads,
queuedItemToFile,
removeQueuedInvoiceUpload,
type MobileUploadQueueItem,
} from "../mobile/uploadQueue";
function formatTime(value: string) {
try {
return new Date(value).toLocaleString("zh-CN");
} catch {
return value;
}
}
function isLikelyNetworkError(error: unknown) {
if (isApiError(error)) {
return error.status >= 500;
}
return true;
}
export function MobileCapturePage() {
const queryClient = useQueryClient();
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
const queueQuery = useQuery({
queryKey: ["mobile", "invoice-upload-queue"],
queryFn: () => listQueuedInvoiceUploads(),
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => uploadInvoice(file, "vat"),
onSuccess: () => {
setSelectedFile(null);
setError("");
setMessage("上传成功");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setError(err instanceof Error ? err.message : "上传失败");
},
});
const syncMutation = useMutation({
mutationFn: async (items: MobileUploadQueueItem[]) => {
let succeeded = 0;
let failed = 0;
for (const item of items) {
try {
const file = queuedItemToFile(item);
await uploadInvoice(file, "vat");
await removeQueuedInvoiceUpload(item.id);
succeeded += 1;
} catch {
failed += 1;
}
}
return { succeeded, failed };
},
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
setError("");
setMessage(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
},
onError: (err) => {
setError(err instanceof Error ? err.message : "队列同步失败");
},
});
const queuedItems = queueQuery.data ?? [];
const queuedCountLabel = useMemo(() => {
if (!queuedItems.length) return "无待上传项";
return `待上传 ${queuedItems.length} 项`;
}, [queuedItems.length]);
useEffect(() => {
const onOnline = () => {
setIsOnline(true);
if (queuedItems.length) {
syncMutation.mutate(queuedItems);
}
};
const onOffline = () => {
setIsOnline(false);
};
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, [queuedItems, syncMutation]);
return (
<div className="space-y-4">
<PageHeader
title="移动拍照上传"
subtitle="支持离线缓存与联网自动重放。"
/>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
{message ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null}
<Card className="space-y-3 p-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">拍照/选择文件</h3>
<span className={isOnline ? "text-emerald-600" : "text-orange-600"}>
{isOnline ? "在线" : "离线"}
</span>
</div>
<input
accept="image/*,application/pdf"
capture="environment"
className="w-full text-sm"
data-testid="mobile-capture-file"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedFile(file);
}}
type="file"
/>
<div className="text-sm text-slate-600">
当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
</div>
<div className="flex flex-wrap gap-2">
<Button
data-testid="mobile-capture-upload"
disabled={uploadMutation.isPending || !selectedFile}
onClick={async () => {
if (!selectedFile) return;
setMessage("");
setError("");
if (!isOnline) {
await enqueueInvoiceUpload(selectedFile);
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
setSelectedFile(null);
setMessage("当前离线,已加入待上传队列");
return;
}
try {
await uploadMutation.mutateAsync(selectedFile);
} catch (err) {
if (isLikelyNetworkError(err)) {
await enqueueInvoiceUpload(selectedFile);
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
setSelectedFile(null);
setError("");
setMessage("上传失败,已加入离线队列等待重试");
return;
}
throw err;
}
}}
type="button"
>
{uploadMutation.isPending ? "上传中..." : "立即上传"}
</Button>
<Button
data-testid="mobile-capture-sync-queue"
disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
onClick={() => syncMutation.mutate(queuedItems)}
type="button"
variant="secondary"
>
{syncMutation.isPending ? "同步中..." : "同步队列"}
</Button>
</div>
</Card>
<Card className="space-y-3 p-4" data-testid="mobile-capture-queue">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">离线上传队列</h3>
<span className="text-sm text-slate-500">{queuedCountLabel}</span>
</div>
{queueQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
{!queueQuery.isLoading && queuedItems.length === 0 ? (
<p className="text-sm text-slate-500">暂无待上传文件。</p>
) : null}
{queuedItems.map((item) => (
<div key={item.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm">
<div className="font-medium text-slate-800">{item.fileName}</div>
<div className="text-slate-500">创建时间:{formatTime(item.createdAt)}</div>
<div className="mt-2">
<Button
data-testid={`mobile-capture-remove-${item.id}`}
onClick={async () => {
await removeQueuedInvoiceUpload(item.id);
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
}}
size="sm"
type="button"
variant="secondary"
>
移除
</Button>
</div>
</div>
))}
</Card>
</div>
);
}
+20
查看文件
@@ -0,0 +1,20 @@
import { registerSW } from "virtual:pwa-register";
export function registerPwaIfEnabled() {
if (import.meta.env.DEV) {
return;
}
if (import.meta.env.VITE_PWA_ENABLED === "false") {
return;
}
registerSW({
immediate: true,
onOfflineReady() {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
}
},
});
}
+30
查看文件
@@ -3,12 +3,15 @@ import { AppShellLayout } from "../layouts/AppShellLayout";
import { AuthLayout } from "../layouts/AuthLayout";
import { AuditLogsPage } from "../pages/AuditLogsPage";
import { ApprovalRulesPage } from "../pages/ApprovalRulesPage";
import { FeatureFlagsPage } from "../pages/FeatureFlagsPage";
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
import { BudgetsPage } from "../pages/BudgetsPage";
import { DashboardPage } from "../pages/DashboardPage";
import { DepartmentsPage } from "../pages/DepartmentsPage";
import { DelegationsPage } from "../pages/DelegationsPage";
import { ForbiddenPage } from "../pages/ForbiddenPage";
import { MobileApprovalsPage } from "../pages/MobileApprovalsPage";
import { MobileCapturePage } from "../pages/MobileCapturePage";
import { InvoicePoolPage } from "../pages/InvoicePoolPage";
import { LoginPage } from "../pages/LoginPage";
import { NotificationsPage } from "../pages/NotificationsPage";
@@ -111,6 +114,24 @@ export function AppRouter() {
}
/>
<Route
path="mobile/capture"
element={
<RequirePermission anyOf={["invoice.upload", "invoice.manage"]}>
<MobileCapturePage />
</RequirePermission>
}
/>
<Route
path="mobile/approvals"
element={
<RequirePermission anyOf={["expense.approve"]}>
<MobileApprovalsPage />
</RequirePermission>
}
/>
<Route path="notifications" element={<NotificationsPage />} />
<Route
@@ -158,6 +179,15 @@ export function AppRouter() {
}
/>
<Route
path="system/feature-flags"
element={
<RequirePermission anyOf={["system.settings"]}>
<FeatureFlagsPage />
</RequirePermission>
}
/>
<Route path="settings/profile" element={<ProfilePage />} />
</Route>
+1
查看文件
@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+49 -1
查看文件
@@ -1,8 +1,56 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
disable: false,
registerType: "autoUpdate",
includeAssets: ["pwa-192x192.png", "pwa-512x512.png", "pwa-maskable-512x512.png"],
manifest: {
name: "hyb-oa 移动工作台",
short_name: "hyb-oa",
description: "海音达工作台移动端入口,支持离线拍照上传与移动审批。",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#2563eb",
icons: [
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "pwa-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
navigateFallback: "/index.html",
globPatterns: ["**/*.{js,css,html,ico,png,svg,webmanifest}"],
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
handler: "NetworkOnly",
},
],
},
devOptions: {
enabled: false,
},
}),
],
server: {
proxy: {
"/api": {
+16 -4
查看文件
@@ -1,8 +1,8 @@
# hyb-oa 实现状态矩阵(P5-P10)
# hyb-oa 实现状态矩阵(P5-P12)
**快照日期**:2026-02-10
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1`,共 80 项。
**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv`(执行合同)
**快照日期**:2026-02-11
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4`,共 92 项。
**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv`(执行合同)
**说明**:
- 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。
@@ -93,3 +93,15 @@
| P10M1 | invoicePool二阶段拆分(读路径) | §16(维护性治理) | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10 | DONE | node --check invoicePool 主/子模块 | none |
| P10M2 | invoicePool二阶段拆分回归与基线更新 | §16(回归与文档) | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10 | DONE | server/scripts/regression-verification.sh + regression-workflow-actions.sh | none |
| P10G1 | 矩阵与执行合同同步到P10 | §16(状态治理) | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10 | DONE | scripts/check-minimal.sh | 覆盖 P5-P10 全量 |
| G0 | P11/P12执行合同建档 | §16(执行合同治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual | none |
| G1 | Feature Flag模型与评估服务 | §16(灰度治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | cd server && npm run prisma:validate | none |
| G2 | 开关管理API与审计 | §16(灰度治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | node --check server/src/routes/featureFlags.js | none |
| G3 | ERP灰度接入(pay+手动sync) | §16(外部集成灰度) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-integrations.sh | 补齐department_id上下文 |
| G4 | 验真开关统一收口(路由+服务+作业) | §16(验真灰度) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-verification.sh | 判定统一收口 |
| G5 | 管理后台开关页 | §16(运营治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | client:typecheck/build | none |
| G6 | 回归脚本与文档收口 | §16(回归工程化) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-feature-flags.sh | none |
| H1 | 安全与压测门槛 | §16(非功能治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/security-baseline.sh + benchmark-dashboard.sh | none |
| M1 | PWA基础设施 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | client:typecheck/build | none |
| M2 | 移动拍照与离线上传队列 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | iOS前台恢复重放 |
| M3 | 移动审批快捷处理 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | none |
| M4 | P12验收与收口 | §16(状态治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | docs/mobile/pwa-regression-checklist.md | none |
+38
查看文件
@@ -0,0 +1,38 @@
# PWA 回归清单(P12)
## 前置条件
- `client` 已完成构建:`cd client && npm run build`
- 浏览器支持 PWA(Chrome/Safari 最新版)
- 服务端 API 可用
## 场景 A:安装与离线壳
1. 打开系统首页,确认可出现安装入口(浏览器原生 UI)。
2. 安装到桌面后重新打开,确认可进入系统壳页面。
3. 断网后刷新,确认仍可打开壳页面(离线壳生效)。
期望:离线壳可访问,不应白屏崩溃。
## 场景 B:移动拍照离线上传
1. 访问 `移动拍照` 页面。
2. 断网,选择图片文件,点击“立即上传”。
3. 确认提示“已加入待上传队列”。
4. 恢复网络,点击“同步队列”或等待自动重放。
5. 进入票据中心确认文件已上传。
期望:离线缓存成功,联网可重放成功。
## 场景 C:移动审批快捷动作
1. 使用审批角色访问 `移动审批` 页面。
2. 选择待办任务,依次测试 `通过` / `驳回` / `退回` / `转审`。
3. 返回待办列表与详情页确认状态同步刷新。
期望:动作闭环,错误提示可读。
## 场景 D:权限与边界
1. 非审批角色访问 `移动审批`,应被拒绝(403页)。
2. 非上传角色访问 `移动拍照`,应被拒绝(403页)。
期望:权限守卫生效。
## 备注
- iOS 场景仅保证前台恢复后的重放,不承诺后台持续同步。
+27
查看文件
@@ -0,0 +1,27 @@
# 非功能基线(P10.5)
更新时间:2026-02-11
## 安全基线
- 使用 `server/scripts/security-baseline.sh` 检查关键安全配置与核心路由语法。
- 发布前要求:
- `JWT_SECRET` 非默认值。
- `ERP_SYNC_ENABLED` / `INVOICE_VERIFICATION_ENABLED` 通过 Feature Flag 治理,默认保持关闭。
- `npm audit --omit=dev --audit-level=high` 输出需评审并记录处理策略。
## 性能基线(Dashboard)
- 使用 `server/scripts/benchmark-dashboard.sh` 对以下端点做基线采样:
- `/api/dashboard/expense-overview`
- `/api/dashboard/trends`
- `/api/dashboard/top-categories`
- `/api/dashboard/approval-efficiency`
- 默认采样次数:`ITERATIONS=10`。
- 建议阈值(测试环境):
- 平均响应(avg)< 500ms
- 最大响应(max)< 1500ms
## 回滚原则
- 若非功能指标异常,先执行配置回滚而非代码回滚:
1. 将 `erp_sync` 和 `invoice_verification` 设为 `force_off`。
2. 必要时将 env 硬闸 `ERP_SYNC_ENABLED=false`、`INVOICE_VERIFICATION_ENABLED=false`。
- 保留审计与历史数据,不做 destructive 数据回滚。
@@ -0,0 +1,13 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
G0,P11/P12执行合同建档,创建并校验P11-P12执行合同CSV且冻结依赖关系,CSV通过校验且字段满足规范,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-11-p11-p12-governance-mobile.csv,manual,DONE,DONE,DONE,issues/2026-02-11-p11-p12-governance-mobile.csv,[],none
G1,Feature Flag模型与评估服务,新增feature_flags数据模型并实现统一评估服务与env硬闸融合,支持erp_sync/invoice_verification两键评估且默认fail-closed,cd server && npm run prisma:validate && node --check src/services/featureFlags/index.js && node --check src/config.js,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations/* | server/src/services/featureFlags/index.js | server/src/config.js | server/.env.example,"[""G0""]",默认两键force_off作为首期安全基线
G2,开关管理API与审计,新增system.settings可访问的Feature Flag查询与更新API并写审计,GET/PUT接口可用且变更具备审计记录,cd server && node --check src/routes/featureFlags.js && node --check src/app.js,manual,DONE,DONE,DONE,server/src/routes/featureFlags.js | server/src/app.js | server/src/routes/audit.js | server/src/utils/audit.js,"[""G1""]",并发更新使用expectedVersion乐观锁
G3,ERP灰度接入(pay+手动sync),pay自动入队与手动ERP同步统一走feature flag并按department_id灰度,pay成功语义不变且非灰度部门阻断手动sync并跳过自动入队,cd server && ./scripts/regression-integrations.sh,manual,DONE,DONE,DONE,server/src/routes/reimbursements.js | server/src/routes/integrations.js | server/src/services/integrations/erp.js | server/src/services/featureFlags/index.js,"[""G2""]",需补齐reimbursement.department_id查询字段
G4,验真开关统一收口(路由+服务+作业),验真判定统一收敛到services/verification/index并覆盖路由与worker,verify/retry/worker均按统一判定执行且关闭时落verification_disabled_by_flag,cd server && ./scripts/regression-verification.sh,manual,DONE,DONE,DONE,server/src/routes/invoicePool/verification.js | server/src/services/verification/index.js | server/src/jobs/verificationJob.js | server/src/jobs/pgBossJobs.js | server/src/services/verification/tax.js | server/src/services/featureFlags/index.js,"[""G2""]",移除分散的config.invoiceVerificationEnabled直接判断
G5,管理后台开关页,新增Feature Flags管理页面并接入权限与导航,system.settings角色可查看并更新开关且前端构建通过,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/FeatureFlagsPage.tsx | client/src/api/featureFlags.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""G2""]",展示effective状态与灰度范围
G6,回归脚本与文档收口,新增feature flag回归脚本并接入总回归与README和矩阵,脚本可重复执行且check-minimal保持通过,cd server && ./scripts/regression-feature-flags.sh && cd /home/shunli/Projects/hyb-oa && ./scripts/check-minimal.sh,manual,DONE,DONE,DONE,server/scripts/regression-feature-flags.sh | server/scripts/regression-all.sh | server/README.md | docs/feature-matrix.md,"[""G3"",""G4"",""G5""]",回滚通过RUN_FEATURE_FLAGS_REGRESSION=0
H1,安全与压测门槛,新增安全基线检查与dashboard压测脚本并形成文档基线,脚本输出可复用且阈值说明可追溯,cd server && ./scripts/security-baseline.sh && ./scripts/benchmark-dashboard.sh,manual,DONE,DONE,DONE,server/scripts/security-baseline.sh | server/scripts/benchmark-dashboard.sh | docs/ops/non-functional-baseline.md,"[""G6""]",默认先观测不阻断
M1,PWA基础设施,在现有client启用PWA并新增manifest与SW注册开关,支持安装与离线壳打开且构建通过,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/vite.config.ts | client/src/main.tsx | client/public/manifest.webmanifest,"[""H1""]",默认仅缓存静态壳与离线fallback
M2,移动拍照与离线上传队列,新增移动拍照上传页及indexeddb离线重放队列,断网缓存并在联网后自动重放上传成功,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/MobileCapturePage.tsx | client/src/mobile/uploadQueue.ts | client/src/api/invoicePool.ts | client/src/router/index.tsx,"[""M1""]",iOS只保证前台恢复后重放
M3,移动审批快捷处理,新增移动审批列表与详情快捷动作页复用workflow接口,移动端可完成approve/reject/return/transfer闭环,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx | client/src/api/workflow.ts | client/src/router/index.tsx,"[""M1""]",保持现有权限守卫策略
M4,P12验收与收口,补充PWA手工回归清单并同步矩阵与执行合同状态,checklist完整且P12状态可审计追溯,manual: 按 docs/mobile/pwa-regression-checklist.md 执行离线上传与移动审批全流程,manual,DONE,DONE,DONE,docs/mobile/pwa-regression-checklist.md | docs/feature-matrix.md | issues/2026-02-11-p11-p12-governance-mobile.csv,"[""M2"",""M3""]",无自动化时必须写可复现手工步骤
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 G0 P11/P12执行合同建档 创建并校验P11-P12执行合同CSV且冻结依赖关系 CSV通过校验且字段满足规范 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-11-p11-p12-governance-mobile.csv manual DONE DONE DONE issues/2026-02-11-p11-p12-governance-mobile.csv [] none
3 G1 Feature Flag模型与评估服务 新增feature_flags数据模型并实现统一评估服务与env硬闸融合 支持erp_sync/invoice_verification两键评估且默认fail-closed cd server && npm run prisma:validate && node --check src/services/featureFlags/index.js && node --check src/config.js manual DONE DONE DONE server/prisma/schema.prisma | server/prisma/migrations/* | server/src/services/featureFlags/index.js | server/src/config.js | server/.env.example ["G0"] 默认两键force_off作为首期安全基线
4 G2 开关管理API与审计 新增system.settings可访问的Feature Flag查询与更新API并写审计 GET/PUT接口可用且变更具备审计记录 cd server && node --check src/routes/featureFlags.js && node --check src/app.js manual DONE DONE DONE server/src/routes/featureFlags.js | server/src/app.js | server/src/routes/audit.js | server/src/utils/audit.js ["G1"] 并发更新使用expectedVersion乐观锁
5 G3 ERP灰度接入(pay+手动sync) pay自动入队与手动ERP同步统一走feature flag并按department_id灰度 pay成功语义不变且非灰度部门阻断手动sync并跳过自动入队 cd server && ./scripts/regression-integrations.sh manual DONE DONE DONE server/src/routes/reimbursements.js | server/src/routes/integrations.js | server/src/services/integrations/erp.js | server/src/services/featureFlags/index.js ["G2"] 需补齐reimbursement.department_id查询字段
6 G4 验真开关统一收口(路由+服务+作业) 验真判定统一收敛到services/verification/index并覆盖路由与worker verify/retry/worker均按统一判定执行且关闭时落verification_disabled_by_flag cd server && ./scripts/regression-verification.sh manual DONE DONE DONE server/src/routes/invoicePool/verification.js | server/src/services/verification/index.js | server/src/jobs/verificationJob.js | server/src/jobs/pgBossJobs.js | server/src/services/verification/tax.js | server/src/services/featureFlags/index.js ["G2"] 移除分散的config.invoiceVerificationEnabled直接判断
7 G5 管理后台开关页 新增Feature Flags管理页面并接入权限与导航 system.settings角色可查看并更新开关且前端构建通过 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/src/pages/FeatureFlagsPage.tsx | client/src/api/featureFlags.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx ["G2"] 展示effective状态与灰度范围
8 G6 回归脚本与文档收口 新增feature flag回归脚本并接入总回归与README和矩阵 脚本可重复执行且check-minimal保持通过 cd server && ./scripts/regression-feature-flags.sh && cd /home/shunli/Projects/hyb-oa && ./scripts/check-minimal.sh manual DONE DONE DONE server/scripts/regression-feature-flags.sh | server/scripts/regression-all.sh | server/README.md | docs/feature-matrix.md ["G3","G4","G5"] 回滚通过RUN_FEATURE_FLAGS_REGRESSION=0
9 H1 安全与压测门槛 新增安全基线检查与dashboard压测脚本并形成文档基线 脚本输出可复用且阈值说明可追溯 cd server && ./scripts/security-baseline.sh && ./scripts/benchmark-dashboard.sh manual DONE DONE DONE server/scripts/security-baseline.sh | server/scripts/benchmark-dashboard.sh | docs/ops/non-functional-baseline.md ["G6"] 默认先观测不阻断
10 M1 PWA基础设施 在现有client启用PWA并新增manifest与SW注册开关 支持安装与离线壳打开且构建通过 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/vite.config.ts | client/src/main.tsx | client/public/manifest.webmanifest ["H1"] 默认仅缓存静态壳与离线fallback
11 M2 移动拍照与离线上传队列 新增移动拍照上传页及indexeddb离线重放队列 断网缓存并在联网后自动重放上传成功 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/src/pages/MobileCapturePage.tsx | client/src/mobile/uploadQueue.ts | client/src/api/invoicePool.ts | client/src/router/index.tsx ["M1"] iOS只保证前台恢复后重放
12 M3 移动审批快捷处理 新增移动审批列表与详情快捷动作页复用workflow接口 移动端可完成approve/reject/return/transfer闭环 cd client && npm run typecheck && npm run build manual DONE DONE DONE client/src/pages/MobileApprovalsPage.tsx | client/src/api/workflow.ts | client/src/router/index.tsx ["M1"] 保持现有权限守卫策略
13 M4 P12验收与收口 补充PWA手工回归清单并同步矩阵与执行合同状态 checklist完整且P12状态可审计追溯 manual: 按 docs/mobile/pwa-regression-checklist.md 执行离线上传与移动审批全流程 manual DONE DONE DONE docs/mobile/pwa-regression-checklist.md | docs/feature-matrix.md | issues/2026-02-11-p11-p12-governance-mobile.csv ["M2","M3"] 无自动化时必须写可复现手工步骤
+5
查看文件
@@ -28,6 +28,7 @@ echo "=== [2/4] Server Static Checks ==="
node --check src/routes/dashboard.js
node --check src/routes/budgets.js
node --check src/routes/integrations.js
node --check src/routes/featureFlags.js
node --check src/routes/travelOrders.js
node --check src/routes/notifications.js
@@ -40,6 +41,7 @@ echo "=== [2/4] Server Static Checks ==="
node --check src/services/ocr/index.js
node --check src/services/verification/index.js
node --check src/services/featureFlags/index.js
node --check src/services/integrations/outbox.js
node --check src/services/integrations/erp.js
node --check src/services/integrations/dispatcher.js
@@ -53,6 +55,9 @@ echo "=== [2/4] Server Static Checks ==="
bash -n scripts/regression-dashboard.sh
bash -n scripts/regression-budget.sh
bash -n scripts/regression-integrations.sh
bash -n scripts/regression-feature-flags.sh
bash -n scripts/security-baseline.sh
bash -n scripts/benchmark-dashboard.sh
bash -n scripts/regression-all.sh
)
+1
查看文件
@@ -23,6 +23,7 @@ CSV_FACT_SOURCES = [
ROOT / "issues" / "2026-02-09_23-30-00-p6-p8-remaining-work-master.csv",
ROOT / "issues" / "2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv",
ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv",
ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv",
]
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z]*$")
+2
查看文件
@@ -57,6 +57,7 @@ OCR_TIMEOUT_MS="5000"
# ===== 发票验真(P6)=====
INVOICE_VERIFICATION_ENABLED="false"
# INVOICE_VERIFICATION_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
VERIFICATION_PROVIDER="mock" # mock|tax
VERIFICATION_POLL_CRON="* * * * *"
VERIFICATION_MAX_ATTEMPTS="3"
@@ -73,6 +74,7 @@ TENCENT_OCR_REGION="ap-beijing"
# ===== 预算与集成(P7-P8)=====
BUDGET_CONTROL_MODE="warn" # warn|block
ERP_SYNC_ENABLED="false"
# ERP_SYNC_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
ERP_SYNC_PROVIDER="mock" # mock|http
ERP_SYNC_ENDPOINT=""
ERP_SYNC_TIMEOUT_MS="5000"
+10
查看文件
@@ -459,6 +459,14 @@ npm run job:ocr-once
- `TRAVEL_SYNC_ENABLED=false`
- `IM_NOTIFIER_ENABLED=false`
### 9.1.1 Feature Flag 治理(P11)
- 新增统一管理 API:
- `GET /api/system/feature-flags`
- `PUT /api/system/feature-flags/:key`
- 首期纳管键:`erp_sync`、`invoice_verification`。
- 优先级规则:`Env硬闸` > `DB在线灰度`。即使 DB 放开,若 Env 为 false 仍会关闭。
- 部门灰度模式:`rolloutType=department_allowlist` 时,仅白名单部门生效。
建议:先在测试组织灰度开启,再逐步扩大范围。
### 9.2 新增 API 摘要
@@ -489,6 +497,7 @@ cd /home/shunli/Projects/hyb-oa/server
./scripts/regression-dashboard.sh
./scripts/regression-budget.sh
./scripts/regression-integrations.sh
./scripts/regression-feature-flags.sh
```
可通过总回归开关启用:
@@ -498,6 +507,7 @@ RUN_VERIFICATION_REGRESSION=1 \
RUN_DASHBOARD_REGRESSION=1 \
RUN_BUDGET_REGRESSION=1 \
RUN_INTEGRATIONS_REGRESSION=1 \
RUN_FEATURE_FLAGS_REGRESSION=1 \
./scripts/regression-all.sh
```
@@ -0,0 +1,31 @@
CREATE TABLE "feature_flags" (
"id" UUID NOT NULL,
"flag_key" VARCHAR(64) NOT NULL,
"override_mode" VARCHAR(16) NOT NULL DEFAULT 'force_off',
"rollout_type" VARCHAR(32) NOT NULL DEFAULT 'global',
"department_ids" JSONB,
"note" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "feature_flags_flag_key_key" ON "feature_flags"("flag_key");
CREATE INDEX "idx_feature_flags_key" ON "feature_flags"("flag_key");
INSERT INTO "feature_flags" (
"id",
"flag_key",
"override_mode",
"rollout_type",
"department_ids",
"note",
"version",
"created_at",
"updated_at"
) VALUES
('6eb0ff4b-912a-4d19-8e4f-c2365604a200'::uuid, 'erp_sync', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('6eb0ff4b-912a-4d19-8e4f-c2365604a201'::uuid, 'invoice_verification', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
+18
查看文件
@@ -608,6 +608,24 @@ model BudgetLedgerEntry {
@@map("budget_ledger_entries")
}
model FeatureFlag {
id String @id @default(uuid()) @db.Uuid
flag_key String @unique @db.VarChar(64)
override_mode String @default("force_off") @db.VarChar(16)
rollout_type String @default("global") @db.VarChar(32)
department_ids Json?
note String?
version Int @default(1)
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
@@index([flag_key], map: "idx_feature_flags_key")
@@map("feature_flags")
}
model IntegrationOutbox {
id String @id @default(uuid()) @db.Uuid
event_type String @db.VarChar(64)
+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"
+2
查看文件
@@ -28,6 +28,7 @@ const employeesRoutes = require("./routes/employees");
const expenseTypesRoutes = require("./routes/expenseTypes");
const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
const featureFlagsRoutes = require("./routes/featureFlags");
const notificationsRoutes = require("./routes/notifications");
const exportsRoutes = require("./routes/exports");
const dashboardRoutes = require("./routes/dashboard");
@@ -229,6 +230,7 @@ async function buildApp() {
await app.register(expenseTypesRoutes, { prefix: "/api" });
await app.register(configReadRoutes, { prefix: "/api" });
await app.register(approvalRulesRoutes, { prefix: "/api" });
await app.register(featureFlagsRoutes, { prefix: "/api" });
await app.register(notificationsRoutes, { prefix: "/api" });
await app.register(dashboardRoutes, { prefix: "/api" });
await app.register(budgetsRoutes, { prefix: "/api" });
+2
查看文件
@@ -91,6 +91,7 @@ function getConfig() {
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true",
invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false",
verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock",
verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *",
verificationMaxAttempts: Number(process.env.VERIFICATION_MAX_ATTEMPTS ?? 3),
@@ -102,6 +103,7 @@ function getConfig() {
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true",
erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false",
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
+14 -2
查看文件
@@ -5,6 +5,10 @@ const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
const { runOcrOnce } = require("./ocrJob");
const { runVerificationForRecord } = require("./verificationJob");
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
const {
FEATURE_FLAG_KEYS,
evaluateFeatureFlag,
} = require("../services/featureFlags");
const JOB_OCR_TICK = "ocr.tick";
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
@@ -267,7 +271,14 @@ async function startPgBossJobs(fastify) {
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
}
if (fastify.config.invoiceVerificationEnabled) {
const verificationScheduling = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
strictDepartment: false,
});
if (verificationScheduling.enabled) {
await boss.schedule(JOB_INVOICE_VERIFY, fastify.config.verificationPollCron, null, {
tz: fastify.config.appTimezone,
singletonKey: JOB_INVOICE_VERIFY,
@@ -288,7 +299,8 @@ async function startPgBossJobs(fastify) {
schema: fastify.config.pgBossSchema,
ocrProvider: fastify.config.ocrProvider,
ocrCron: fastify.config.ocrPollCron,
verificationEnabled: fastify.config.invoiceVerificationEnabled,
verificationEnabled: verificationScheduling.enabled,
verificationFlagReason: verificationScheduling.reason,
verificationCron: fastify.config.verificationPollCron,
delegationCron: fastify.config.delegationRevokeCron,
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
+45 -1
查看文件
@@ -1,4 +1,7 @@
const { verifyInvoiceByProvider } = require("../services/verification");
const {
evaluateVerificationAccess,
verifyInvoiceByProvider,
} = require("../services/verification");
function toResultStatus(status) {
if (["passed", "failed", "error"].includes(status)) {
@@ -32,6 +35,16 @@ async function runVerificationForRecord(prisma, config, recordId) {
total_amount: true,
remarks: true,
status: true,
owner_employee: {
select: {
department_id: true,
},
},
linked_reimbursement: {
select: {
department_id: true,
},
},
},
},
},
@@ -54,6 +67,37 @@ async function runVerificationForRecord(prisma, config, recordId) {
return { skipped: true, reason: "status_not_pending" };
}
const departmentId =
record.invoice.linked_reimbursement?.department_id ??
record.invoice.owner_employee?.department_id ??
null;
const access = await evaluateVerificationAccess({
prisma: tx,
config,
departmentId,
strictDepartment: true,
});
if (!access.enabled) {
await tx.invoiceVerificationRecord.update({
where: { id: recordId },
data: {
status: "error",
error_code: "verification_disabled_by_flag",
error_message: access.reason,
completed_at: new Date(),
},
});
return {
skipped: false,
status: "error",
durationMs: 0,
resultCode: "verification_disabled_by_flag",
};
}
const startedAt = Date.now();
const verification = await verifyInvoiceByProvider({
config,
+171
查看文件
@@ -0,0 +1,171 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const {
FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST,
ROLLOUT_TYPE_LIST,
evaluateFeatureFlag,
listFeatureFlags,
updateFeatureFlag,
} = require("../services/featureFlags");
const KEY_PARAM_SCHEMA = z.object({
key: z.enum(FEATURE_FLAG_KEY_LIST),
});
const UPDATE_BODY_SCHEMA = z.object({
overrideMode: z.enum(OVERRIDE_MODE_LIST),
rolloutType: z.enum(ROLLOUT_TYPE_LIST),
departmentIds: z.array(z.string().uuid()).default([]),
note: z.string().trim().max(500).optional().default(""),
expectedVersion: z.coerce.number().int().min(1),
});
function parseKey(params) {
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data.key;
}
function parseUpdateBody(body) {
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { departmentIds: "rollout_type_global_disallows_department_ids" },
});
}
return parsed.data;
}
async function featureFlagsRoutes(fastify) {
fastify.get(
"/system/feature-flags",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const items = await listFeatureFlags(fastify.prisma, fastify.config);
return sendData(reply, request, { items });
},
);
fastify.put(
"/system/feature-flags/:key",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const key = parseKey(request.params);
const body = parseUpdateBody(request.body);
const result = await updateFeatureFlag(fastify.prisma, {
flagKey: key,
overrideMode: body.overrideMode,
rolloutType: body.rolloutType,
departmentIds: body.departmentIds,
note: body.note,
expectedVersion: body.expectedVersion,
actorId: request.employee.id,
});
if (result.status === "not_found" || !result.current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (result.status === "version_conflict") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
featureFlag: "version_conflict",
expectedVersion: body.expectedVersion,
currentVersion: result.current.version,
},
});
}
if (result.status !== "updated" || !result.updated) {
throw new AppError({
code: "SYS_001",
message: "服务器内部错误",
statusCode: 500,
});
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "FEATURE_FLAG_UPDATED",
targetType: "feature_flag",
targetId: result.updated.id,
extra: {
flagKey: key,
from: {
overrideMode: result.current.overrideMode,
rolloutType: result.current.rolloutType,
departmentIds: result.current.departmentIds,
note: result.current.note,
version: result.current.version,
},
to: {
overrideMode: result.updated.overrideMode,
rolloutType: result.updated.rolloutType,
departmentIds: result.updated.departmentIds,
note: result.updated.note,
version: result.updated.version,
},
},
}),
});
const effective = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: key,
strictDepartment: false,
});
return sendData(reply, request, {
item: {
...result.updated,
envEnabled: effective.envEnabled,
effectiveEnabled: effective.enabled,
effectiveReason: effective.reason,
matchedDepartmentId: effective.matchedDepartmentId,
requiresDepartment: effective.requiresDepartment,
},
});
},
);
}
module.exports = featureFlagsRoutes;
+26 -9
查看文件
@@ -5,6 +5,7 @@ const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
async function integrationsRoutes(fastify) {
@@ -14,15 +15,6 @@ async function integrationsRoutes(fastify) {
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.erpSyncEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { integration: "erp_sync_disabled" },
});
}
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
if (!parsedId.success) {
throw new AppError({
@@ -42,6 +34,7 @@ async function integrationsRoutes(fastify) {
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
status: true,
total_amount: true,
payment_voucher_no: true,
@@ -67,6 +60,27 @@ async function integrationsRoutes(fastify) {
});
}
const erpGate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: true,
});
if (!erpGate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
integration: "feature_flag_blocked",
featureFlag: FEATURE_FLAG_KEYS.erpSync,
reason: erpGate.reason,
},
});
}
const payload = buildErpPayload({ reimbursement });
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
eventType: "erp.reimbursement.paid",
@@ -85,6 +99,9 @@ async function integrationsRoutes(fastify) {
extra: {
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId: reimbursement.department_id ?? null,
featureFlagReason: erpGate.reason,
},
}),
});
+100 -36
查看文件
@@ -3,12 +3,87 @@ const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildAuditLogData } = require("../../utils/audit");
const { getProvider, isVerificationEnabled } = require("../../services/verification");
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
const { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas");
const { triggerVerificationJob } = require("./shared/helpers");
function throwVerificationDisabled(reason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled", featureFlag: reason },
});
}
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
owner_employee: {
select: {
id: true,
department_id: true,
},
},
linked_reimbursement: {
select: {
id: true,
department_id: true,
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const departmentId =
invoice.linked_reimbursement?.department_id ??
invoice.owner_employee?.department_id ??
request.employee.department_id ??
null;
return {
invoice,
departmentId,
};
}
async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) {
const context = await resolveVerificationDepartmentContext(tx, request, invoiceId);
const evaluation = await evaluateVerificationAccess({
prisma: tx,
config: fastify.config,
departmentId: context.departmentId,
strictDepartment: true,
});
if (!evaluation.enabled) {
throwVerificationDisabled(evaluation.reason);
}
return {
...context,
evaluation,
};
}
function registerInvoiceVerificationRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/verify",
@@ -16,15 +91,6 @@ function registerInvoiceVerificationRoutes(fastify) {
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
@@ -54,24 +120,12 @@ function registerInvoiceVerificationRoutes(fastify) {
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
},
const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({
tx,
fastify,
request,
invoiceId,
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
@@ -116,6 +170,8 @@ function registerInvoiceVerificationRoutes(fastify) {
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: invoice.total_amount?.toString?.() ?? null,
departmentId,
featureFlagReason: evaluation.reason,
},
requested_by: request.employee.id,
requested_at: new Date(now),
@@ -135,6 +191,8 @@ function registerInvoiceVerificationRoutes(fastify) {
extra: {
verificationId: created.id,
provider,
departmentId,
featureFlagReason: evaluation.reason,
},
}),
});
@@ -142,6 +200,8 @@ function registerInvoiceVerificationRoutes(fastify) {
return {
verificationId: created.id,
provider,
departmentId,
featureFlagReason: evaluation.reason,
};
});
@@ -157,6 +217,8 @@ function registerInvoiceVerificationRoutes(fastify) {
extra: {
verificationId: response.verificationId,
status: trigger.result?.status ?? null,
departmentId: response.departmentId,
featureFlagReason: response.featureFlagReason,
},
}),
});
@@ -259,15 +321,6 @@ function registerInvoiceVerificationRoutes(fastify) {
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
@@ -282,6 +335,13 @@ function registerInvoiceVerificationRoutes(fastify) {
const provider = getProvider(fastify.config);
const created = await fastify.prisma.$transaction(async (tx) => {
const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({
tx,
fastify,
request,
invoiceId,
});
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
@@ -344,6 +404,8 @@ function registerInvoiceVerificationRoutes(fastify) {
requested_at: new Date(),
request_payload: {
retryOfId: latest.id,
departmentId,
featureFlagReason: evaluation.reason,
},
},
select: {
@@ -361,6 +423,8 @@ function registerInvoiceVerificationRoutes(fastify) {
verificationId: createdRecord.id,
retryOfId: latest.id,
attempts,
departmentId,
featureFlagReason: evaluation.reason,
},
}),
});
+36 -8
查看文件
@@ -11,6 +11,7 @@ const {
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
@@ -1691,7 +1692,7 @@ async function reimbursementRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, form_number, owner_id, submitter_id,
SELECT id, status, form_number, owner_id, submitter_id, department_id,
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
@@ -1772,6 +1773,7 @@ async function reimbursementRoutes(fastify) {
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
@@ -1779,14 +1781,40 @@ async function reimbursementRoutes(fastify) {
},
});
if (refreshed && fastify.config.erpSyncEnabled) {
await enqueueIntegrationEvent(tx, {
eventType: "erp.reimbursement.paid",
dedupeKey: refreshed.id,
payload: buildErpPayload({ reimbursement: refreshed }),
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
if (refreshed) {
const departmentId =
refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null;
const erpGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (erpGate.enabled) {
await enqueueIntegrationEvent(tx, {
eventType: "erp.reimbursement.paid",
dedupeKey: refreshed.id,
payload: buildErpPayload({ reimbursement: refreshed }),
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
});
} else {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
targetType: "reimbursement_form",
targetId: refreshed.id,
extra: {
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
reason: erpGate.reason,
},
}),
});
}
}
try {
+390
查看文件
@@ -0,0 +1,390 @@
const FEATURE_FLAG_KEYS = {
erpSync: "erp_sync",
invoiceVerification: "invoice_verification",
};
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
const OVERRIDE_MODE_LIST = Object.freeze(["inherit", "force_on", "force_off"]);
const ROLLOUT_TYPE_LIST = Object.freeze(["global", "department_allowlist"]);
function isKnownFeatureFlagKey(flagKey) {
return FEATURE_FLAG_KEY_LIST.includes(flagKey);
}
function normalizeOverrideMode(value) {
const mode = String(value ?? "force_off").trim().toLowerCase();
if (OVERRIDE_MODE_LIST.includes(mode)) {
return mode;
}
return "force_off";
}
function normalizeRolloutType(value) {
const type = String(value ?? "global").trim().toLowerCase();
if (ROLLOUT_TYPE_LIST.includes(type)) {
return type;
}
return "global";
}
function sanitizeDepartmentIds(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const items = [];
for (const id of value) {
if (typeof id !== "string") {
continue;
}
const trimmed = id.trim();
if (!trimmed) {
continue;
}
if (!seen.has(trimmed)) {
seen.add(trimmed);
items.push(trimmed);
}
}
return items;
}
function resolveEnvGate(config, flagKey) {
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
return Boolean(config.erpSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) {
return Boolean(config.invoiceVerificationEnabled);
}
return false;
}
function mapFlagRecord(record) {
if (!record) {
return null;
}
const departmentIds = sanitizeDepartmentIds(record.department_ids);
const overrideMode = normalizeOverrideMode(record.override_mode);
const rolloutType = normalizeRolloutType(record.rollout_type);
return {
id: record.id,
flagKey: record.flag_key,
overrideMode,
rolloutType,
departmentIds,
note: record.note ?? "",
version: Number(record.version ?? 1),
createdAt: record.created_at?.toISOString?.() ?? null,
createdBy: record.created_by ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
function buildDefaultRecord(flagKey) {
return {
id: null,
flagKey,
overrideMode: "force_off",
rolloutType: "global",
departmentIds: [],
note: "",
version: 1,
createdAt: null,
createdBy: null,
updatedAt: null,
updatedBy: null,
};
}
function withEvaluation(base, evaluation) {
return {
...base,
envEnabled: evaluation.envEnabled,
effectiveEnabled: evaluation.enabled,
effectiveReason: evaluation.reason,
matchedDepartmentId: evaluation.matchedDepartmentId,
requiresDepartment: evaluation.requiresDepartment,
};
}
async function getFlagRecord(prisma, flagKey) {
const row = await prisma.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return mapFlagRecord(row);
}
async function evaluateFeatureFlag({
prisma,
config,
flagKey,
departmentId = null,
strictDepartment = true,
}) {
const envEnabled = resolveEnvGate(config, flagKey);
const normalizedDepartmentId =
typeof departmentId === "string" && departmentId.trim() ? departmentId.trim() : null;
if (!envEnabled) {
return {
flagKey,
enabled: false,
reason: "env_hard_off",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const record = await getFlagRecord(prisma, flagKey);
if (!record) {
return {
flagKey,
enabled: false,
reason: "missing_flag_record",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (!OVERRIDE_MODE_LIST.includes(record.overrideMode)) {
return {
flagKey,
enabled: false,
reason: "invalid_override_mode",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (!ROLLOUT_TYPE_LIST.includes(record.rolloutType)) {
return {
flagKey,
enabled: false,
reason: "invalid_rollout_type",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (record.overrideMode === "force_off") {
return {
flagKey,
enabled: false,
reason: "flag_force_off",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const modeEnabled = record.overrideMode === "force_on" || record.overrideMode === "inherit";
if (!modeEnabled) {
return {
flagKey,
enabled: false,
reason: "mode_not_enabled",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (record.rolloutType === "global") {
return {
flagKey,
enabled: true,
reason: record.overrideMode === "inherit" ? "env_inherit_global_on" : "flag_force_on_global",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const allowlist = record.departmentIds;
if (!allowlist.length) {
return {
flagKey,
enabled: false,
reason: "department_allowlist_empty",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
if (!normalizedDepartmentId) {
if (strictDepartment) {
return {
flagKey,
enabled: false,
reason: "department_missing",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
return {
flagKey,
enabled: true,
reason: "department_deferred",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
const matched = allowlist.includes(normalizedDepartmentId);
if (!matched) {
return {
flagKey,
enabled: false,
reason: "department_not_allowed",
envEnabled,
matchedDepartmentId: normalizedDepartmentId,
requiresDepartment: true,
};
}
return {
flagKey,
enabled: true,
reason:
record.overrideMode === "inherit"
? "env_inherit_department_allowed"
: "flag_force_on_department_allowed",
envEnabled,
matchedDepartmentId: normalizedDepartmentId,
requiresDepartment: true,
};
}
async function listFeatureFlags(prisma, config) {
const rows = await prisma.featureFlag.findMany({
where: {
flag_key: { in: FEATURE_FLAG_KEY_LIST },
},
orderBy: { flag_key: "asc" },
});
const recordMap = new Map(rows.map((row) => [row.flag_key, mapFlagRecord(row)]));
const items = [];
for (const flagKey of FEATURE_FLAG_KEY_LIST) {
const base = recordMap.get(flagKey) ?? buildDefaultRecord(flagKey);
const evaluation = await evaluateFeatureFlag({
prisma,
config,
flagKey,
departmentId: null,
strictDepartment: false,
});
items.push(withEvaluation(base, evaluation));
}
return items;
}
async function updateFeatureFlag(prisma, input) {
const flagKey = String(input.flagKey ?? "").trim();
if (!isKnownFeatureFlagKey(flagKey)) {
return {
status: "not_found",
current: null,
updated: null,
};
}
const overrideMode = normalizeOverrideMode(input.overrideMode);
const rolloutType = normalizeRolloutType(input.rolloutType);
const departmentIds =
rolloutType === "department_allowlist" ? sanitizeDepartmentIds(input.departmentIds) : [];
return prisma.$transaction(async (tx) => {
const currentRow = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
if (!currentRow) {
return {
status: "not_found",
current: null,
updated: null,
};
}
const current = mapFlagRecord(currentRow);
if (Number(input.expectedVersion) !== current.version) {
return {
status: "version_conflict",
current,
updated: null,
};
}
const updateResult = await tx.featureFlag.updateMany({
where: {
id: current.id,
version: current.version,
},
data: {
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: input.note ?? "",
version: {
increment: 1,
},
updated_by: input.actorId ?? null,
},
});
if (updateResult.count !== 1) {
const latest = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return {
status: "version_conflict",
current: mapFlagRecord(latest),
updated: null,
};
}
const updatedRow = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return {
status: "updated",
current,
updated: mapFlagRecord(updatedRow),
};
});
}
module.exports = {
FEATURE_FLAG_KEYS,
FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST,
ROLLOUT_TYPE_LIST,
isKnownFeatureFlagKey,
sanitizeDepartmentIds,
evaluateFeatureFlag,
listFeatureFlags,
updateFeatureFlag,
};
+24
查看文件
@@ -1,5 +1,6 @@
const { runMockVerification } = require("./mock");
const { runTaxVerification } = require("./tax");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
function getProvider(config) {
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
@@ -9,6 +10,28 @@ function isVerificationEnabled(config) {
return Boolean(config.invoiceVerificationEnabled);
}
async function evaluateVerificationAccess({
prisma,
config,
departmentId = null,
strictDepartment = true,
}) {
const evaluation = await evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
departmentId,
strictDepartment,
});
return {
enabled: evaluation.enabled,
reason: evaluation.reason,
matchedDepartmentId: evaluation.matchedDepartmentId,
envEnabled: evaluation.envEnabled,
};
}
async function verifyInvoiceByProvider({ config, invoice }) {
const provider = getProvider(config);
@@ -33,5 +56,6 @@ async function verifyInvoiceByProvider({ config, invoice }) {
module.exports = {
getProvider,
isVerificationEnabled,
evaluateVerificationAccess,
verifyInvoiceByProvider,
};
-11
查看文件
@@ -1,15 +1,4 @@
async function runTaxVerification({ config, invoice }) {
if (!config.invoiceVerificationEnabled) {
return {
status: "error",
resultCode: "verification_disabled",
resultMessage: "invoice verification disabled",
payload: {
provider: "tax",
},
};
}
return {
status: "error",
resultCode: "tax_provider_not_configured",