为高安全性实时AI应用构建tRPC、WebAuthn与TensorFlow.js的全栈技术栈选型与实现


定义问题:一个无法妥协的安全与实时性需求

我们需要构建一个内部专家审查系统。场景是金融风控,专家需要实时审查并标注由机器学习模型标记的可疑交易。该系统的技术要求极为苛刻:

  1. 顶级安全性: 鉴于数据的敏感性,认证过程必须能抵御网络钓鱼和中间人攻击。传统的密码+2FA组合存在被钓鱼的风险,不予考虑。
  2. 端到端类型安全: 这是一个快速迭代的项目,前后端接口的频繁变更不能成为产生运行时错误的温床。从数据库模式到前端UI组件的类型必须自动保持一致。
  3. 实时交互性: 专家审查的每一个操作——无论是调整输入参数还是提交标注——都必须得到瞬时响应。模型推理的延迟必须控制在毫秒级。
  4. 数据隐私: 原始交易数据在任何情况下都不应离开用户浏览器,以满足最严格的合规要求。所有模型推理必须在客户端完成。
  5. 精简的状态管理: UI逻辑复杂,但团队不希望陷入状态管理的繁文缛节中。解决方案必须直观且样板代码少。

方案A:传统但脆弱的组合

在进行技术选型时,首先评估的是一套业界成熟的“标准”方案。

  • 认证: 基于JWT的Session机制,存储在HttpOnly Cookie中。
  • API层: RESTful API,通过OpenAPI/Swagger生成规范,手动维护客户端类型。
  • 机器学习: 后端Python服务(如Flask/FastAPI)封装TensorFlow/PyTorch模型,提供推理API。
  • 前端状态管理: Redux配合Redux Toolkit和RTK Query处理异步数据流。

方案A的劣势分析

这套方案看似稳妥,但在我们的苛刻要求下,其弱点暴露无遗:

  1. 安全缺口: 即便使用HttpOnly Cookie,JWT方案本质上依赖于会话凭证。它无法有效抵御针对用户的复杂钓鱼攻击,一旦凭证泄露,账户即被盗用。在金融风控场景中,这个风险是不可接受的。
  2. 类型安全的断裂带: REST + OpenAPI的组合在实践中非常脆弱。API变更后,需要重新生成规范、发布SDK、客户端更新依赖。这个流程充满了人为错误的可能性,无法实现真正的端到端类型安全。一个常见的错误是,后端字段重命名后,前端的类型定义未及时更新,导致运行时出现undefined错误,直到集成测试才被发现。
  3. 无法容忍的延迟: 每次推理都需要将数据从客户端发送到服务器,经过网络传输、服务器处理、模型计算,再返回结果。即使在内网环境,这个往返延迟(RTT)也至少是几十毫秒,完全破坏了实时交互的体验。
  4. 数据隐私风险: 将原始交易数据发送到服务器进行推理,即便在内网也增加了数据暴露的攻击面,违背了我们的核心隐私原则。
  5. 状态管理的复杂性: 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的优势与权衡

  1. 安全性质变: WebAuthn是行业金标准。它基于公钥密码学,私钥永不离开认证设备。这从根本上消除了网络钓鱼的可能性。这是一个决定性的优势。
  2. 无缝的类型体验: tRPC是这个方案的粘合剂。通过共享TypeScript类型定义,后端API路由的任何变更都会立刻在前端的TypeScript编译器中报错。无需代码生成,没有同步延迟。这极大地提升了开发效率和系统的健壮性。
  3. 极致的实时性与隐私: TensorFlow.js将模型直接带到用户设备上。推理在本地进行,延迟仅取决于客户端CPU/GPU性能,通常在毫秒级。数据从不离开浏览器,完美解决了隐私问题。
  4. 直观的状态心智模型: 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)。submitAnnotationctx.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

Loading AI Model…
;
}

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

  目录