定义问题:一个无法妥协的安全与实时性需求
我们需要构建一个内部专家审查系统。场景是金融风控,专家需要实时审查并标注由机器学习模型标记的可疑交易。该系统的技术要求极为苛刻:
- 顶级安全性: 鉴于数据的敏感性,认证过程必须能抵御网络钓鱼和中间人攻击。传统的密码+2FA组合存在被钓鱼的风险,不予考虑。
- 端到端类型安全: 这是一个快速迭代的项目,前后端接口的频繁变更不能成为产生运行时错误的温床。从数据库模式到前端UI组件的类型必须自动保持一致。
- 实时交互性: 专家审查的每一个操作——无论是调整输入参数还是提交标注——都必须得到瞬时响应。模型推理的延迟必须控制在毫秒级。
- 数据隐私: 原始交易数据在任何情况下都不应离开用户浏览器,以满足最严格的合规要求。所有模型推理必须在客户端完成。
- 精简的状态管理: UI逻辑复杂,但团队不希望陷入状态管理的繁文缛节中。解决方案必须直观且样板代码少。
方案A:传统但脆弱的组合
在进行技术选型时,首先评估的是一套业界成熟的“标准”方案。
- 认证: 基于JWT的Session机制,存储在HttpOnly Cookie中。
- API层: RESTful API,通过OpenAPI/Swagger生成规范,手动维护客户端类型。
- 机器学习: 后端Python服务(如Flask/FastAPI)封装TensorFlow/PyTorch模型,提供推理API。
- 前端状态管理: Redux配合Redux Toolkit和RTK Query处理异步数据流。
方案A的劣势分析
这套方案看似稳妥,但在我们的苛刻要求下,其弱点暴露无遗:
- 安全缺口: 即便使用HttpOnly Cookie,JWT方案本质上依赖于会话凭证。它无法有效抵御针对用户的复杂钓鱼攻击,一旦凭证泄露,账户即被盗用。在金融风控场景中,这个风险是不可接受的。
- 类型安全的断裂带: REST + OpenAPI的组合在实践中非常脆弱。API变更后,需要重新生成规范、发布SDK、客户端更新依赖。这个流程充满了人为错误的可能性,无法实现真正的端到端类型安全。一个常见的错误是,后端字段重命名后,前端的类型定义未及时更新,导致运行时出现
undefined错误,直到集成测试才被发现。 - 无法容忍的延迟: 每次推理都需要将数据从客户端发送到服务器,经过网络传输、服务器处理、模型计算,再返回结果。即使在内网环境,这个往返延迟(RTT)也至少是几十毫秒,完全破坏了实时交互的体验。
- 数据隐私风险: 将原始交易数据发送到服务器进行推理,即便在内网也增加了数据暴露的攻击面,违背了我们的核心隐私原则。
- 状态管理的复杂性: Redux是强大的工具,但其固有的样板代码(actions, reducers, selectors, thunks)对于我们这个聚焦于快速交互的UI来说过于笨重。RTK Query虽有改善,但整个心智模型依然复杂。
方案B:拥抱现代Web技术栈
基于方案A的不足,我们设计了一套更激进、更现代的方案。
- 认证: WebAuthn(FIDO2),利用硬件密钥(如YubiKey)或平台认证器(如Windows Hello, Touch ID)进行无密码认证。
- API层: tRPC,实现真正意义上的端到端类型安全API。
- 机器学习: TensorFlow.js,在浏览器中直接运行模型,实现零延迟、高隐私的推理。
- 前端状态管理: Valtio,一个基于Proxy的极简状态管理库。
方案B的优势与权衡
- 安全性质变: WebAuthn是行业金标准。它基于公钥密码学,私钥永不离开认证设备。这从根本上消除了网络钓鱼的可能性。这是一个决定性的优势。
- 无缝的类型体验: tRPC是这个方案的粘合剂。通过共享TypeScript类型定义,后端API路由的任何变更都会立刻在前端的TypeScript编译器中报错。无需代码生成,没有同步延迟。这极大地提升了开发效率和系统的健壮性。
- 极致的实时性与隐私: TensorFlow.js将模型直接带到用户设备上。推理在本地进行,延迟仅取决于客户端CPU/GPU性能,通常在毫秒级。数据从不离开浏览器,完美解决了隐私问题。
- 直观的状态心智模型: Valtio通过JavaScript Proxy将普通对象变为响应式状态。修改对象属性即可触发UI更新,没有actions或reducers。其心智负担极低,非常适合需要频繁、局部更新状态的复杂UI。
当然,方案B也有其成本:
- 技术新颖性: 团队对WebAuthn和tRPC的熟悉度不如REST/JWT,需要投入学习成本。
- 客户端负载: TensorFlow.js模型的大小会影响应用的初始加载时间。模型本身的计算复杂度也受限于客户端设备的性能。
- 耦合性: tRPC的设计哲学是前后端紧密耦合,适用于全栈TypeScript应用,但不适合作为需要版本化、向后兼容的公共API。在我们的内部系统场景下,这恰好是优点。
决策: 考虑到安全性是第一优先级,且实时交互体验是核心用户价值,我们最终选择方案B。其带来的开发体验提升和系统健壮性增强,足以抵消学习成本和对客户端性能的额外考量。
核心实现概览
我们将项目构建为一个基于pnpm workspaces的monorepo,结构如下:
/packages
/api # Express + tRPC后端
/app # Next.js前端应用
/common # 共享的类型定义、Zod schemas等
1. 后端:tRPC与WebAuthn的深度集成
后端的关键在于将WebAuthn的认证状态安全地注入到tRPC的上下文中。
a. 依赖安装
# packages/api
pnpm add express cors @trpc/server zod
pnpm add @simplewebauthn/server @simplewebauthn/typescript-types
# 伪造一个用户数据库
pnpm add lowdb
b. WebAuthn认证服务
WebAuthn的流程分为注册和验证两步。我们需要为每一步创建服务端逻辑。这里使用@simplewebauthn/server库简化流程。
// packages/api/src/webauthn/service.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
VerifiedRegistrationResponse,
VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import { db } from '../db'; // 假设的lowdb实例
import type { User, Authenticator } from '../db';
const rpID = 'localhost';
const origin = `http://${rpID}:3000`;
const rpName = 'High Security AI System';
// 注册挑战生成
export const getRegistrationOptions = (user: User) => {
const userAuthenticators = db.data.authenticators.filter(
(auth) => auth.userId === user.id,
);
return generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.username,
// 不允许用户重复注册同一个设备
excludeCredentials: userAuthenticators.map((auth) => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
};
// 注册挑战验证
export const verifyRegistration = async (
user: User,
responseBody: any,
): Promise<boolean> => {
try {
const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({
response: responseBody,
expectedChallenge: user.currentChallenge!,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
if (verification.verified && verification.registrationInfo) {
const { registrationInfo } = verification;
const newAuthenticator: Authenticator = {
userId: user.id,
...registrationInfo,
};
db.data.authenticators.push(newAuthenticator);
await db.write();
return true;
}
return false;
} catch (error) {
console.error('Registration verification failed:', error);
return false;
}
};
// ... 登录挑战生成与验证 (getAuthenticationOptions, verifyAuthentication) 逻辑类似 ...
这段代码是生产级的,它包含了excludeCredentials来防止重复注册,并要求用户验证(requireUserVerification),这是安全最佳实践。
c. tRPC上下文与受保护的Procedure
我们将通过一个中间件来验证WebAuthn凭证,并将用户信息注入tRPC上下文。在真实项目中,这个凭证通常以某种形式(如自定义头)随每个请求发送,并在后端进行验证和会话管理。为简化示例,我们假设登录后会建立一个临时的、安全的会话。
// packages/api/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { CreateExpressContextOptions } from '@trpc/server/adapters/express';
import { db } from './db';
// 模拟会话管理
const sessionStore = new Map<string, { userId: string; loggedInAt: number }>();
export const createContext = ({ req, res }: CreateExpressContextOptions) => {
const sessionId = req.headers['x-session-id'] as string | undefined;
if (!sessionId) {
return { user: null };
}
const session = sessionStore.get(sessionId);
if (!session) {
return { user: null };
}
// 简单的会话过期检查
if (Date.now() - session.loggedInAt > 1000 * 60 * 30) { // 30分钟
sessionStore.delete(sessionId);
return { user: null };
}
const user = db.data.users.find((u) => u.id === session.userId);
return { user: user || null };
};
const t = initTRPC.context<typeof createContext>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// 中间件,用于保护需要认证的路由
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User is not authenticated' });
}
return next({
ctx: {
// 推断出user是非空的
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
d. 核心tRPC路由
// packages/api/src/router.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from './trpc';
import { getRegistrationOptions, verifyRegistration /* ...auth fns */ } from './webauthn/service';
// ... 模拟的用户查找和会话创建逻辑 ...
export const appRouter = router({
// --- Auth Endpoints ---
getRegistrationOptions: publicProcedure
.input(z.object({ username: z.string() }))
.query(async ({ input }) => {
// ... 查找或创建用户,并保存挑战 ...
}),
verifyRegistration: publicProcedure
.input(z.object({ username: z.string(), response: z.any() }))
.mutation(async ({ input }) => {
// ... 验证逻辑 ...
}),
// ... 登录相关的 getAuthenticationOptions 和 verifyAuthentication ...
// --- Protected Endpoints ---
submitAnnotation: protectedProcedure
.input(z.object({
transactionId: z.string(),
isFraudulent: z.boolean(),
expertNotes: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
console.log(`User ${ctx.user.username} annotated transaction ${input.transactionId}`);
// 这里的ctx.user是类型安全的,并且保证存在
// ... 存储标注结果到数据库 ...
return { success: true, message: `Annotation for ${input.transactionId} saved.` };
}),
getSensitiveData: protectedProcedure
.query(({ ctx }) => {
return { secret: `This is for you, ${ctx.user.username}` };
}),
});
export type AppRouter = typeof appRouter;
这里清晰地划分了公共过程(publicProcedure)和受保护过程(protectedProcedure)。submitAnnotation的ctx.user被TypeScript正确推断为非null类型,这就是tRPC中间件的威力。
2. 前端:Valtio, TensorFlow.js与tRPC客户端的协同
前端的核心是将WebAuthn的复杂流程、TensorFlow.js的实时推理和tRPC的数据请求优雅地组织在一起。
a. 依赖安装
# packages/app
pnpm add @trpc/client @trpc/server @trpc/react-query @tanstack/react-query
pnpm add valtio
pnpm add @tensorflow/tfjs
pnpm add @simplewebauthn/browser
b. tRPC客户端配置
// packages/app/src/utils/trpc.ts
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../../api/src/router';
import { appState } from '../state'; // Valtio store
export const trpc = createTRPCReact<AppRouter>();
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:4000/trpc',
headers() {
// 从Valtio store中读取会话ID并附加到请求头
const sessionId = appState.sessionId;
return sessionId ? { 'x-session-id': sessionId } : {};
},
}),
],
});
这里的关键是headers函数,它动态地从我们的状态管理器(Valtio)中读取会话ID,并将其注入到每个tRPC请求中。
c. Valtio状态管理
Valtio的简洁性在此处大放异彩。我们只需要一个简单的对象来管理整个应用的全局状态。
// packages/app/src/state.ts
import { proxy, snapshot } from 'valtio';
import { devtools } from 'valtio/utils';
interface AppState {
isAuthenticated: boolean;
username: string | null;
sessionId: string | null;
model: tf.LayersModel | null;
modelLoading: boolean;
inferenceResult: number[] | null;
isInferring: boolean;
}
export const appState = proxy<AppState>({
isAuthenticated: false,
username: null,
sessionId: null,
model: null,
modelLoading: true,
inferenceResult: null,
isInferring: false,
});
// 在开发环境中与Redux DevTools集成
const unsub = devtools(appState, { name: 'App State' });
不需要Actions,不需要Reducers。在组件中直接修改appState.isAuthenticated = true即可。
d. TensorFlow.js模型加载与推理
我们创建一个hook来封装模型加载逻辑,并将其状态同步到Valtio store。
// packages/app/src/hooks/useModel.ts
import * as tf from '@tensorflow/tfjs';
import { useEffect } from 'react';
import { appState } from '../state';
const MODEL_URL = '/model/model.json'; // 假设模型放在public目录下
export function useModel() {
useEffect(() => {
async function loadModel() {
if (appState.model) return;
try {
console.log('Loading TensorFlow.js model...');
appState.modelLoading = true;
const loadedModel = await tf.loadLayersModel(MODEL_URL);
appState.model = loadedModel;
console.log('Model loaded successfully.');
} catch (error) {
console.error('Failed to load model:', error);
} finally {
appState.modelLoading = false;
}
}
loadModel();
}, []);
const runInference = (inputData: number[][]): void => {
if (!appState.model) {
console.warn('Model not loaded yet, cannot run inference.');
return;
}
try {
appState.isInferring = true;
const tensor = tf.tensor2d(inputData);
const prediction = appState.model.predict(tensor) as tf.Tensor;
appState.inferenceResult = Array.from(prediction.dataSync());
prediction.dispose();
tensor.dispose();
} catch (error) {
console.error('Inference error:', error);
appState.inferenceResult = null;
} finally {
appState.isInferring = false;
}
};
return { runInference };
}
注意这里的资源管理:tensor.dispose()和prediction.dispose()是手动释放WebGL纹理内存的关键,能有效防止客户端内存泄漏。
e. 核心UI组件
最后,我们将所有部分组合在一个React组件中。
```tsx
// packages/app/src/components/ReviewPanel.tsx
import { useSnapshot } from ‘valtio’;
import { appState } from ‘../state’;
import { useModel } from ‘../hooks/useModel’;
import { trpc } from ‘../utils/trpc’;
import { startAuthentication, startRegistration } from ‘@simplewebauthn/browser’;
export function ReviewPanel() {
const snap = useSnapshot(appState);
const { runInference } = useModel();
// tRPC mutations and queries
const registrationOptionsQuery = trpc.getRegistrationOptions.useQuery({ username: ‘expert-user’ }, { enabled: false });
// … 其他tRPC hooks
const handleLogin = async () => {
// 1. 从后端获取登录挑战
// 2. 调用 startAuthentication
// 3. 将结果发送到后端验证
// 4. 成功后,后端返回sessionId, 更新Valtio store
// appState.isAuthenticated = true;
// appState.sessionId = serverSessionId;
};
const handleInference = () => {
// 模拟交易数据
const transactionData = [[1.2, 0.5, 3.4, …]];
runInference(transactionData);
};
const submitAnnotation = trpc.submitAnnotation.useMutation();
if (!snap.isAuthenticated) {
return ;
}
if (snap.modelLoading) {
return
}
return (
Welcome, {snap.username}
{snap.inferenceResult && (
<div>
<h2>Inference Result: {JSON.stringify(snap.inferenceResult)}</h2>
<button onClick={() => submitAnnotation.mutate({
transactionId: 'txn-123',
isFraudulent: snap.inferenceResult