项目脚手架

342 浏览
31 试用
8 购买
Nov 24, 2025更新

根据用户指定的项目类型、技术框架及用途生成完整项目脚手架代码,包含必要元素和基础结构,确保代码高质量、可复用,帮助开发者快速搭建项目模板,加速开发启动。

下面提供一套基于 React 18 + TypeScript + Vite 的前端管理后台脚手架样板代码,满足你提出的功能与工程规范需求。内容包含项目目录结构、关键代码示例、配置、测试、Mock、CI/CD、部署与常用脚本。包管理工具使用 pnpm,状态管理采用 Redux Toolkit 并提供可替换为 Zustand 的抽象接口,路由使用 React Router v6 并包含布局/懒加载/路由守卫,鉴权提供 JWT 存储与刷新、RBAC 权限中间件。API 层支持 REST/GraphQL 可切换,包含拦截器、重试与缓存。内置数据看板、订单看板、用户与角色、商品与库存 CRUD 表格、导入导出 CSV/Excel、全局消息与异常上报、多语言与暗黑模式以及响应式与无障碍支持。

一、快速开始

  • 环境要求:Node >= 18,pnpm >= 8
  • 初始化
    • pnpm dlx degit <本模板仓库> inventory-order-admin
    • 或复制以下文件到新仓库并执行安装
  • 安装依赖:pnpm install
  • 启动 Mock 与开发:pnpm dev
  • 构建产物:pnpm build
  • 预览产物:pnpm preview
  • 单元测试:pnpm test
  • 端到端测试:pnpm test:e2e
  • 代码检查:pnpm lint
  • 类型检查:pnpm typecheck
  • 初始化提交钩子:pnpm run setup:hooks

二、目录结构

  • src/app
  • src/pages
  • src/components
  • src/features
  • src/services
  • src/store
  • src/hooks
  • src/router
  • src/assets
  • src/styles
  • src/utils
  • mock
  • scripts
  • 其他工程配置文件位于根目录

三、关键文件与代码示例

  1. package.json { "name": "inventory-order-admin", "version": "0.1.0", "private": true, "packageManager": "pnpm@8.10.0", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview --port 5000", "lint": "eslint . --ext .ts,.tsx", "typecheck": "tsc --noEmit", "test": "vitest --run", "test:ui": "vitest", "test:e2e": "playwright test", "setup:hooks": "node scripts/setup-hooks.mjs", "mock:gen": "node scripts/generate-mock.mjs", "report:errors": "node scripts/report-errors.mjs", "format": "prettier --write "{src,mock}/**/*.{ts,tsx,css,md}"" }, "dependencies": { "@reduxjs/toolkit": "^1.9.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.1.2", "react-router-dom": "^6.21.2", "react-i18next": "^12.3.1", "i18next": "^23.7.10", "classnames": "^2.3.2", "zod": "^3.23.8", "papaparse": "^5.4.1", "xlsx": "^0.18.5", "echarts": "^5.5.0", "graphql-request": "^6.1.0" }, "devDependencies": { "@commitlint/cli": "^18.4.3", "@commitlint/config-conventional": "^18.4.3", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "@types/node": "^20.10.5", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "husky": "^9.0.11", "lint-staged": "^15.2.0", "msw": "^2.3.1", "playwright": "^1.40.1", "prettier": "^3.1.1", "typescript": "^5.3.3", "vite": "^5.0.11", "vitest": "^1.2.2" } }

  2. 环境变量与 Vite 配置

  • .env.development VITE_API_BASE=/api VITE_GRAPHQL_ENDPOINT=/graphql VITE_DATA_CLIENT=rest VITE_ENABLE_MOCK=1 VITE_APP_NAME=InventoryAdmin VITE_DEFAULT_LOCALE=zh VITE_ENABLE_ERROR_REPORT=1

  • .env.production VITE_API_BASE=https://api.example.com VITE_GRAPHQL_ENDPOINT=https://api.example.com/graphql VITE_DATA_CLIENT=graphql VITE_ENABLE_MOCK=0 VITE_APP_NAME=InventoryAdmin VITE_DEFAULT_LOCALE=en VITE_ENABLE_ERROR_REPORT=1

  • vite.config.ts import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react-swc';

export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); return { plugins: [react()], server: { port: 5173, proxy: env.VITE_ENABLE_MOCK === '0' ? { '/api': { target: env.VITE_API_BASE, changeOrigin: true }, '/graphql': { target: env.VITE_GRAPHQL_ENDPOINT, changeOrigin: true } } : undefined }, build: { sourcemap: true, rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom'], vendor: ['react-router-dom', 'echarts'] } } } } }; });

  1. 入口与应用框架
  • src/main.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './app/App'; import './styles/global.css';

if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK === '1') { const { worker } = await import('./mocks/browser'); await worker.start({ onUnhandledRequest: 'bypass' }); }

ReactDOM.createRoot(document.getElementById('root')!).render();

  • src/app/App.tsx import React from 'react'; import { BrowserRouter } from 'react-router-dom'; import { StoreProvider } from './providers/StoreProvider'; import { ThemeProvider } from './providers/ThemeProvider'; import { I18nProvider } from './providers/I18nProvider'; import { Router } from '../router'; import { MessageProvider } from '../components/Message/MessageProvider';

export const App: React.FC = () => ( );

  • src/app/providers/ThemeProvider.tsx import React, { useEffect, useState, createContext, useContext } from 'react'; const ThemeCtx = createContext({ dark: false, toggle: () => {} }); export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [dark, setDark] = useState(() => localStorage.getItem('theme') === 'dark'); useEffect(() => { document.documentElement.classList.toggle('dark', dark); localStorage.setItem('theme', dark ? 'dark' : 'light'); }, [dark]); return <ThemeCtx.Provider value={{ dark, toggle: () => setDark(d => !d) }}>{children}</ThemeCtx.Provider>; }; export const useTheme = () => useContext(ThemeCtx);

  • src/app/providers/I18nProvider.tsx import React from 'react'; import './i18n'; // side-effect init export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => <>{children}</>;

  • src/app/providers/StoreProvider.tsx import React from 'react'; import { Provider } from 'react-redux'; import { store } from '../../store'; export const StoreProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} );

  1. 路由与守卫
  • src/router/index.tsx import React, { Suspense, lazy } from 'react'; import { useRoutes, Navigate } from 'react-router-dom'; import { AppLayout } from '../components/Layout/AppLayout'; import { AuthGuard } from './guards/AuthGuard'; import { PermissionGuard } from './guards/PermissionGuard';

const Login = lazy(() => import('../pages/Login')); const Register = lazy(() => import('../pages/Register')); const Dashboard = lazy(() => import('../pages/Dashboard')); const Users = lazy(() => import('../pages/Users')); const Roles = lazy(() => import('../pages/Roles')); const Products = lazy(() => import('../pages/Products')); const Inventory = lazy(() => import('../pages/Inventory')); const OrdersKanban = lazy(() => import('../pages/OrdersKanban')); const Settings = lazy(() => import('../pages/Settings'));

export const Router = () => { const routes = useRoutes([ { path: '/login', element: }, { path: '/register', element: }, { path: '/', element: ( ), children: [ { index: true, element: }, { path: 'dashboard', element: }, { path: 'users', element: ( <PermissionGuard roles={['admin']}> ) }, { path: 'roles', element: ( <PermissionGuard roles={['admin']}> ) }, { path: 'products', element: }, { path: 'inventory', element: }, { path: 'orders', element: }, { path: 'settings', element: } ] }, { path: '*', element: } ]); return routes; };

  • src/router/guards/AuthGuard.tsx import React from 'react'; import { useAppSelector } from '../../store/hooks'; import { Navigate, useLocation } from 'react-router-dom'; export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { const isAuthed = useAppSelector(s => !!s.auth.accessToken); const loc = useLocation(); if (!isAuthed) return <Navigate to="/login" state={{ from: loc }} replace />; return <>{children}</>; };

  • src/router/guards/PermissionGuard.tsx import React from 'react'; import { useAppSelector } from '../../store/hooks'; import { hasRole } from '../../utils/rbac'; export const PermissionGuard: React.FC<{ roles?: string[]; children: React.ReactNode }> = ({ roles = [], children }) => { const userRoles = useAppSelector(s => s.auth.user?.roles || []); if (roles.length && !hasRole(userRoles, roles)) return <>403 Forbidden</>; return <>{children}</>; };

  • src/utils/rbac.ts export const hasRole = (userRoles: string[], required: string[]) => required.every(r => userRoles.includes(r));

  1. 状态管理:Redux Toolkit(可替换为 Zustand)
  • src/store/index.ts import { configureStore } from '@reduxjs/toolkit'; import authReducer from '../features/auth/authSlice'; import usersReducer from '../features/users/usersSlice'; import productsReducer from '../features/products/productsSlice'; import inventoryReducer from '../features/inventory/inventorySlice'; import ordersReducer from '../features/orders/ordersSlice';

export const store = configureStore({ reducer: { auth: authReducer, users: usersReducer, products: productsReducer, inventory: inventoryReducer, orders: ordersReducer }, middleware: getDefault => getDefault() });

export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch;

// hooks import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector;

  • src/store/adapter.ts(可替换为 Zustand 的抽象) export interface StoreAdapter { getState: () => any; dispatch: (action: any) => void; subscribe?: (listener: () => void) => () => void; } export const reduxAdapter: StoreAdapter = { getState: () => store.getState(), dispatch: (action) => store.dispatch(action), subscribe: (listener) => store.subscribe(listener) }; // 示例:Zustand 适配器(可根据需求替换) /* import create from 'zustand'; const useZStore = create(set => ({ auth: {}, dispatch: (a:any)=>set(a) })); export const zustandAdapter: StoreAdapter = { getState: () => useZStore.getState(), dispatch: (action) => useZStore.getState().dispatch(action) }; */

  • src/features/auth/authSlice.ts import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import { authApi } from '../../services/auth'; type User = { id: string; name: string; roles: string[]; email: string }; type AuthState = { accessToken?: string; refreshToken?: string; user?: User; loading: boolean; }; const initialState: AuthState = { loading: false }; export const login = createAsyncThunk('auth/login', async (payload: { email: string; password: string }) => { return authApi.login(payload.email, payload.password); }); export const fetchMe = createAsyncThunk('auth/me', async () => authApi.me()); const slice = createSlice({ name: 'auth', initialState, reducers: { logout(state) { state.accessToken = undefined; state.refreshToken = undefined; state.user = undefined; authApi.logout(); } }, extraReducers: b => { b.addCase(login.pending, s => { s.loading = true; }); b.addCase(login.fulfilled, (s, a) => { s.loading = false; s.accessToken = a.payload.accessToken; s.refreshToken = a.payload.refreshToken; s.user = a.payload.user; }); b.addCase(login.rejected, s => { s.loading = false; }); b.addCase(fetchMe.fulfilled, (s, a) => { s.user = a.payload; }); } }); export const { logout } = slice.actions; export default slice.reducer;

其他 features 如 users/products/inventory/orders 可按照 Slice + asyncThunk 的模式,示例见 mock handlers。

  1. API 层:REST/GraphQL 可切换、拦截器/重试/缓存
  • src/services/http.ts type Interceptor = (arg: T) => Promise | T; class Cache { private map = new Map<string, { ts: number; data: any }>(); constructor(private ttl = 5000) {} get(key: string) { const v = this.map.get(key); if (!v) return; if (Date.now() - v.ts > this.ttl) { this.map.delete(key); return; } return v.data; } set(key: string, data: any) { this.map.set(key, { ts: Date.now(), data }); } } const cache = new Cache(3000);

export interface RequestOptions extends RequestInit { retry?: number; cache?: boolean; } const requestInterceptors: Interceptor[] = []; const responseInterceptors: Interceptor[] = [];

export const addRequestInterceptor = (fn: Interceptor) => requestInterceptors.push(fn); export const addResponseInterceptor = (fn: Interceptor) => responseInterceptors.push(fn);

export const httpFetch = async (url: string, opts: RequestOptions = {}) => { const base = import.meta.env.VITE_API_BASE || ''; const fullUrl = url.startsWith('http') ? url : ${base}${url}; const cacheKey = ${opts.method || 'GET'}:${fullUrl}; if (opts.cache) { const hit = cache.get(cacheKey); if (hit) return hit; } const req = new Request(fullUrl, { ...opts, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) } }); let processedReq = req; for (const i of requestInterceptors) processedReq = await i(processedReq);

let attempt = 0, max = opts.retry ?? 1; let res: Response | undefined; let err; while (attempt <= max) { try { res = await fetch(processedReq); for (const i of responseInterceptors) res = await i(res!); if (!res.ok) throw new Error(HTTP ${res.status}); const data = await res.json(); if (opts.cache) cache.set(cacheKey, data); return data; } catch (e) { err = e; attempt++; if (attempt > max) break; await new Promise(r => setTimeout(r, 200 * Math.pow(2, attempt))); } } throw err; };

  • src/services/auth.ts import { httpFetch, addRequestInterceptor, addResponseInterceptor } from './http'; const TOKEN_KEY = 'access_token'; const REFRESH_KEY = 'refresh_token'; const setToken = (t?: string) => t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); const setRefresh = (t?: string) => t ? localStorage.setItem(REFRESH_KEY, t) : localStorage.removeItem(REFRESH_KEY); export const getToken = () => localStorage.getItem(TOKEN_KEY) || undefined; export const getRefresh = () => localStorage.getItem(REFRESH_KEY) || undefined;

addRequestInterceptor(async (req) => { const token = getToken(); if (token) return new Request(req, { headers: { ...Object.fromEntries(req.headers), Authorization: Bearer ${token} } }); return req; });

addResponseInterceptor(async (res) => { if (res.status !== 401) return res; const refresh = getRefresh(); if (!refresh) return res; try { const data = await fetch(${import.meta.env.VITE_API_BASE}/auth/refresh, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: refresh }) }).then(r => r.json()); setToken(data.accessToken); setRefresh(data.refreshToken); // 这里可选择重放原请求,简化起见由上层调用重试 } catch {} return res; });

export const authApi = { async login(email: string, password: string) { const data = await httpFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), retry: 1 }); setToken(data.accessToken); setRefresh(data.refreshToken); return data; }, async register(name: string, email: string, password: string) { return httpFetch('/auth/register', { method: 'POST', body: JSON.stringify({ name, email, password }) }); }, async me() { return httpFetch('/auth/me', { cache: true }); }, logout() { setToken(undefined); setRefresh(undefined); } };

  • src/services/dataClient.ts(REST/GraphQL 切换) import { httpFetch } from './http'; import { GraphQLClient, gql } from 'graphql-request'; const mode = import.meta.env.VITE_DATA_CLIENT; const gqlClient = new GraphQLClient(import.meta.env.VITE_GRAPHQL_ENDPOINT);

export const dataClient = { async listUsers() { if (mode === 'graphql') { const query = gql{ users { id name email roles } }; const r: any = await gqlClient.request(query); return r.users; } return httpFetch('/users', { cache: true }); }, async createUser(dto: any) { if (mode === 'graphql') { const mutation = gqlmutation($input:UserInput!){ createUser(input:$input){ id } }; const r: any = await gqlClient.request(mutation, { input: dto }); return r.createUser; } return httpFetch('/users', { method: 'POST', body: JSON.stringify(dto) }); } // 其他 resources: products, inventory, orders... };

  1. 基础 UI 组件与布局
  • src/components/Layout/AppLayout.tsx import React from 'react'; import { Outlet, NavLink } from 'react-router-dom'; import { useTheme } from '../../app/providers/ThemeProvider'; export const AppLayout: React.FC = () => { const { dark, toggle } = useTheme(); return (
    Skip to content
    );

};

  • src/components/Table/DataTable.tsx import React from 'react'; type Col = { key: keyof T; title: string; render?: (v: any, row: T) => React.ReactNode }; export function DataTable<T extends Record<string, any>>({ columns, data }: { columns: Col[]; data: T[] }) { return ( {columns.map(c => )}{data.map((row, i) => {columns.map(c => )})}
    {c.title}
    {c.render ? c.render(row[c.key], row) : String(row[c.key])}
    );

}

  • src/components/Chart/Chart.tsx import React, { useEffect, useRef } from 'react'; import * as echarts from 'echarts'; export const Chart: React.FC<{ option: echarts.EChartsOption; height?: number }> = ({ option, height = 300 }) => { const ref = useRef(null); useEffect(() => { if (!ref.current) return; const inst = echarts.init(ref.current); inst.setOption(option); const onResize = () => inst.resize(); window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); inst.dispose(); }; }, [option]); return <div ref={ref} style={{ height }} aria-label="chart" />; };

  • src/components/Message/MessageProvider.tsx import React, { createContext, useContext, useState } from 'react'; type Msg = { id: number; type: 'info'|'success'|'error'; text: string }; const Ctx = createContext({ notify: (m: Omit<Msg,'id'>) => {} }); export const MessageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [list, setList] = useState<Msg[]>([]); const notify = (m: Omit<Msg,'id'>) => setList(prev => [...prev, { id: Date.now(), ...m }]); return ( <Ctx.Provider value={{ notify }}> {children}

    {list.map(m => <div key={m.id} role="status" className={msg ${m.type}}>{m.text}
    )}
</Ctx.Provider> ); }; export const useMessage = () => useContext(Ctx);

  1. 页面示例(登录/注册、数据看板、CRUD、订单看板)
  • src/pages/Login.tsx import React, { useState } from 'react'; import { useAppDispatch } from '../store'; import { login } from '../features/auth/authSlice'; import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useMessage } from '../components/Message/MessageProvider'; export default function Login() { const [email, setEmail] = useState('admin@example.com'); const [password, setPassword] = useState('admin123'); const dispatch = useAppDispatch(); const nav = useNavigate(); const loc = useLocation(); const { notify } = useMessage(); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await dispatch(login({ email, password })).unwrap(); nav((loc.state as any)?.from?.pathname || '/'); notify({ type:'success', text:'Login success' }); } catch { notify({ type:'error', text:'Login failed' }); } }; return (

    Login

    New here? Register

    );

}

  • src/pages/Dashboard.tsx import React, { useEffect, useState } from 'react'; import { Chart } from '../components/Chart/Chart'; import { httpFetch } from '../services/http'; export default function Dashboard() { const [metrics, setMetrics] = useState<{ sales:number; orders:number; stock:number } | null>(null); useEffect(() => { httpFetch('/metrics', { cache: true }).then(setMetrics); }, []); const option = { xAxis: { type:'category', data:['Sales','Orders','Stock'] }, yAxis:{ type:'value' }, series:[{ type:'bar', data:[metrics?.sales||0, metrics?.orders||0, metrics?.stock||0] }] }; return (

    Dashboard

    );

}

  • src/pages/Users.tsx(用户管理 CRUD 表) import React, { useEffect, useState } from 'react'; import { DataTable } from '../components/Table/DataTable'; import { dataClient } from '../services/dataClient'; export default function Users() { const [list, setList] = useState<any[]>([]); useEffect(() => { dataClient.listUsers().then(setList); }, []); return

    Users

    <DataTable columns={[ { key: 'name', title: 'Name' }, { key: 'email', title: 'Email' }, { key: 'roles', title: 'Roles', render: (v) => Array.isArray(v) ? v.join(', ') : v } ]} data={list} />
    ; }

  • src/pages/OrdersKanban.tsx(订单流程看板) import React, { useEffect, useState } from 'react'; import { httpFetch } from '../services/http'; type Order = { id:string; title:string; status:'pending'|'processing'|'shipped'|'delivered' }; export default function OrdersKanban() { const [orders, setOrders] = useState<Order[]>([]); useEffect(() => { httpFetch('/orders', { cache: true }).then(setOrders); }, []); const cols = ['pending','processing','shipped','delivered'] as const; return (

    {cols.map(c =>

    {c}

    {orders.filter(o=>o.status===c).map(o=>
    {o.title}
    )}
    )}
    );

}

  • 导入导出工具:src/utils/csv.ts import Papa from 'papaparse'; export const parseCSV = (file: File) => new Promise<any[]>((resolve, reject) => Papa.parse(file, { header: true, complete: r => resolve(r.data as any[]), error: reject })); export const exportCSV = (rows: any[], filename='export.csv') => { const csv = Papa.unparse(rows); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); };

  • 导入导出 Excel:src/utils/excel.ts import * as XLSX from 'xlsx'; export const parseExcel = async (file: File) => { const data = await file.arrayBuffer(); const wb = XLSX.read(data); const ws = wb.Sheets[wb.SheetNames[0]]; return XLSX.utils.sheet_to_json(ws); }; export const exportExcel = (rows: any[], filename='export.xlsx') => { const ws = XLSX.utils.json_to_sheet(rows); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Sheet1'); XLSX.writeFile(wb, filename); };

  1. 多语言 i18n
  • src/app/i18n.ts import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; const resources = { en: { translation: { login: 'Login', dashboard: 'Dashboard' } }, zh: { translation: { login: '登录', dashboard: '数据看板' } } }; i18n.use(initReactI18next).init({ resources, lng: import.meta.env.VITE_DEFAULT_LOCALE || 'en', fallbackLng: 'en', interpolation: { escapeValue: false } }); export default i18n;
  1. 全局异常上报
  • src/utils/errorReporter.ts export const reportError = (err: any) => { if (import.meta.env.VITE_ENABLE_ERROR_REPORT !== '1') return; // 可发送到后端或 Sentry,这里示例 POST fetch('/api/errors', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ message: String(err?.message || err), stack: String(err?.stack||'') }) }); }; window.addEventListener('error', e => reportError(e.error || e)); window.addEventListener('unhandledrejection', e => reportError(e.reason));
  1. 样式与可访问性
  • src/styles/global.css :root { color-scheme: light dark; } html.dark { background: #121212; color: #eaeaea; } .layout { display:flex; min-height:100vh; } .sidebar { width: 240px; border-right: 1px solid #ddd; padding: 1rem; } main { flex:1; padding:1rem; } .skip-link { position:absolute; left:-10000px; top:auto; } .skip-link:focus { left:0; top:0; }
  1. 接口模拟与数据 Mock(REST/GraphQL)
  • src/mocks/browser.ts import { setupWorker } from 'msw'; import { handlers } from '../../mock/handlers'; export const worker = setupWorker(...handlers);

  • mock/handlers.ts import { http, graphql } from 'msw'; const users = [{ id:'1', name:'Admin', email:'admin@example.com', roles:['admin'] }, { id:'2', name:'User', email:'user@example.com', roles:['user'] }]; const products = [{ id:'p1', name:'Keyboard', price:99, stock:50 }, { id:'p2', name:'Mouse', price:49, stock:80 }]; const orders = [{ id:'o1', title:'Order #1', status:'pending' }, { id:'o2', title:'Order #2', status:'processing' }]; export const handlers = [ http.post('/api/auth/login', async ({ request }) => { const body = await request.json(); if (body.email && body.password) return new Response(JSON.stringify({ accessToken:'fake-jwt', refreshToken:'fake-refresh', user:users[0] }), { status:200 }); return new Response('Unauthorized', { status:401 }); }), http.post('/api/auth/register', async () => new Response(JSON.stringify({ ok:true }), { status:200 })), http.post('/api/auth/refresh', async () => new Response(JSON.stringify({ accessToken:'new-fake-jwt', refreshToken:'new-fake-refresh' }), { status:200 })), http.get('/api/auth/me', async () => new Response(JSON.stringify(users[0]), { status:200 })), http.get('/api/users', async () => new Response(JSON.stringify(users), { status:200 })), http.post('/api/users', async ({ request }) => { const u = await request.json(); users.push({ id: String(Date.now()), ...u }); return new Response(JSON.stringify({ ok:true }), { status:200 }); }), http.get('/api/products', async () => new Response(JSON.stringify(products), { status:200 })), http.get('/api/inventory', async () => new Response(JSON.stringify(products.map(p=>({ id:p.id, stock:p.stock }))), { status:200 })), http.get('/api/orders', async () => new Response(JSON.stringify(orders), { status:200 })), http.get('/api/metrics', async () => new Response(JSON.stringify({ sales: 1200, orders: 42, stock: 300 }), { status:200 })), graphql.query('users', (_req, res, ctx) => res(ctx.data({ users }))), graphql.mutation('createUser', (_req, res, ctx) => res(ctx.data({ createUser: { id: String(Date.now()) } }))) ];

  1. 测试:Vitest + Playwright
  • vitest.config.ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react-swc'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./test/setup.ts'], coverage: { reporter: ['text', 'html'] } } });

  • test/setup.ts import '@testing-library/jest-dom';

  • 示例单元测试:src/services/http.test.ts import { describe, it, expect } from 'vitest'; import { httpFetch } from './http'; describe('httpFetch', () => { it('gets metrics', async () => { const data = await httpFetch('/metrics', { cache: true }); expect(data).toHaveProperty('sales'); }); });

  • playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: 'e2e', webServer: { command: 'pnpm dev', port: 5173, reuseExistingServer: !process.env.CI }, use: { headless: true } });

  • e2e/login.spec.ts import { test, expect } from '@playwright/test'; test('login page should render', async ({ page }) => { await page.goto('http://localhost:5173/login'); await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); });

  1. 质量保障:ESLint、提交规范、预提交钩子
  • .eslintrc.cjs module.exports = { root: true, parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint', 'react', 'react-hooks'], extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'prettier'], settings: { react: { version: '18.0' } }, env: { browser: true, node: true, es2022: true }, rules: { 'react/react-in-jsx-scope': 'off', '@typescript-eslint/no-explicit-any': 'off', 'react/prop-types': 'off' } };

  • commitlint.config.cjs module.exports = { extends: ['@commitlint/config-conventional'] };

  • scripts/setup-hooks.mjs import { execSync } from 'node:child_process'; execSync('pnpm dlx husky init'); execSync('pnpm husky add .husky/pre-commit "pnpm lint-staged"'); execSync('pnpm husky add .husky/commit-msg "pnpm dlx commitlint --edit $1"'); console.log('Husky hooks installed');

  • lint-staged.config.cjs module.exports = { '.{ts,tsx}': ['eslint --fix', 'vitest related --run --changed'], '.{css,md}': ['prettier --write'] };

  1. 部署:Dockerfile + Nginx
  • Dockerfile

Build stage

FROM node:18-alpine AS build WORKDIR /app COPY . . RUN corepack enable && pnpm install --frozen-lockfile RUN pnpm build

Runtime stage

FROM nginx:alpine COPY nginx/default.conf /etc/nginx/conf.d/default.conf COPY dist /usr/share/nginx/html EXPOSE 80 CMD ["nginx","-g","daemon off;"]

  • nginx/default.conf server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://backend:8080; } location /graphql { proxy_pass http://backend:8080/graphql; } }
  1. CI/CD:GitHub Actions 工作流
  • .github/workflows/ci.yml name: CI on: push: branches: [ main ] pull_request: jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '18', cache: 'pnpm' } - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck - run: pnpm test -- --coverage - name: Build run: pnpm build - name: Upload artifact uses: actions/upload-artifact@v3 with: { name: dist, path: dist } e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '18', cache: 'pnpm' } - run: corepack enable - run: pnpm install --frozen-lockfile - run: npx playwright install --with-deps - run: pnpm test:e2e
  1. 脚本:scripts/*
  • scripts/generate-mock.mjs import fs from 'node:fs'; fs.writeFileSync('mock/generated.json', JSON.stringify({ timestamp: Date.now() }, null, 2)); console.log('Generated mock data');

  • scripts/report-errors.mjs import fs from 'node:fs'; const file = 'logs/errors.log'; if (!fs.existsSync(file)) fs.writeFileSync(file, ''); console.log('Error log initialized');

四、可扩展说明

  • REST/GraphQL 切换:通过 VITE_DATA_CLIENT=rest|graphql 控制 dataClient 行为,REST 走 httpFetch,GraphQL 走 graphql-request。
  • RBAC:在路由 meta 中声明 roles,PermissionGuard 判断用户 roles 是否满足。
  • JWT 刷新:响应拦截器在 401 时尝试刷新;业务请求可通过 retry 参数自动重试。
  • 代码分块与懒加载:路由组件使用 React.lazy;Vite rollupOptions.manualChunks 拆分 vendor 包,提高首屏性能。
  • 多语言与暗黑模式:ThemeProvider 切换 dark class;i18n 根据环境变量默认语言加载资源。
  • 响应式与可访问性:提供 skip-link、语义化标签与 aria 属性,样式包含基本响应式布局。
  • 替换为 Zustand:可实现 StoreAdapter 的 zustandAdapter,并在 StoreProvider 中注入对应 adapter,或以 hooks 方式替换 useAppSelector/useAppDispatch。

如需我将以上模板打包为可直接 degit 的仓库结构或根据你团队的具体 API 字段与权限模型进一步完善 CRUD 表单、表格筛选/分页、图表配置面板以及异常上报对接第三方服务(如 Sentry),请继续说明。

下面提供一套可直接使用的 NestJS + TypeScript BFF 网关样板工程,满足你的功能与结构要求。内容包含目录结构、核心代码文件、配置、数据库与缓存、队列、监控、测试、部署与 CI/CD。可直接使用 yarn 运行与构建。

目录结构

  • src/
    • main.ts
    • app.module.ts
    • app.controller.ts
    • app.service.ts
    • common/
      • constants/error-codes.ts
      • filters/http-exception.filter.ts
      • interceptors/response.interceptor.ts
      • interceptors/audit.interceptor.ts
      • interceptors/metrics.interceptor.ts
      • guards/jwt-auth.guard.ts
      • guards/roles.guard.ts
      • pipes/validation.pipe.ts
      • decorators/roles.decorator.ts
      • decorators/cached.decorator.ts
      • utils/trace.util.ts
    • config/
      • configuration.ts
      • validation.ts
      • swagger.ts
      • cors.ts
      • throttler.ts
      • otel.ts
    • database/
      • database.module.ts
      • typeorm.config.ts
      • migrations/
        • 1700000000000-InitSchema.ts
      • seeds/
        • seed.ts
        • factories/
          • user.factory.ts
    • modules/
      • auth/
        • auth.module.ts
        • auth.service.ts
        • auth.controller.ts
        • strategies/jwt-access.strategy.ts
        • strategies/jwt-refresh.strategy.ts
        • dtos/login.dto.ts
        • dtos/refresh.dto.ts
      • users/
        • users.module.ts
        • users.controller.ts
        • users.service.ts
        • dtos/create-user.dto.ts
        • dtos/update-user.dto.ts
        • entities/user.entity.ts
      • products/
        • products.module.ts
        • products.controller.ts
        • products.service.ts
        • dtos/create-product.dto.ts
        • dtos/update-product.dto.ts
        • entities/product.entity.ts
      • orders/
        • orders.module.ts
        • orders.controller.ts
        • orders.service.ts
        • dtos/create-order.dto.ts
        • dtos/update-order.dto.ts
        • entities/order.entity.ts
      • cache/
        • cache.module.ts
        • cache.service.ts
      • queue/
        • queue.module.ts
        • mail.processor.ts
        • reconcile.processor.ts
      • health/
        • health.module.ts
        • health.controller.ts
      • metrics/
        • metrics.module.ts
        • metrics.controller.ts
    • tracing/
      • tracing.ts
  • test/
    • unit/
      • users.service.spec.ts
    • e2e/
      • app.e2e-spec.ts
      • jest-e2e.json
  • .env.example
  • ormconfig.ts
  • package.json
  • tsconfig.json
  • jest.config.js
  • Dockerfile
  • docker-compose.yml
  • .github/workflows/ci.yml
  • .eslintrc.js
  • .prettierignore(如需)
  • scripts/
    • start-prod.sh

关键代码示例

  1. 启动与 App 模块 src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from './common/pipes/validation.pipe';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { AuditInterceptor } from './common/interceptors/audit.interceptor';
import { MetricsInterceptor } from './common/interceptors/metrics.interceptor';
import { setupSwagger } from './config/swagger';
import { setupCors } from './config/cors';
import { setupThrottler } from './config/throttler';
import { initTracing } from './tracing/tracing';

async function bootstrap() {
  initTracing();

  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,
  });

  setupCors(app);
  setupThrottler(app);

  app.useGlobalPipes(new ValidationPipe());
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new MetricsInterceptor(), new AuditInterceptor(), new ResponseInterceptor());

  setupSwagger(app);

  const port = process.env.PORT || 3000;
  await app.listen(port);
  // eslint-disable-next-line no-console
  console.log(`BFF API listening on http://localhost:${port}`);
}
bootstrap();

src/app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';
import { validateEnv } from './config/validation';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { ProductsModule } from './modules/products/products.module';
import { OrdersModule } from './modules/orders/orders.module';
import { CacheModule as BffCacheModule } from './modules/cache/cache.module';
import { QueueModule } from './modules/queue/queue.module';
import { HealthModule } from './modules/health/health.module';
import { MetricsModule } from './modules/metrics/metrics.module';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
      validate: validateEnv,
      envFilePath: ['.env'],
    }),
    DatabaseModule,
    BffCacheModule,
    QueueModule,
    AuthModule,
    UsersModule,
    ProductsModule,
    OrdersModule,
    HealthModule,
    MetricsModule,
  ],
  providers: [
    { provide: APP_GUARD, useClass: ThrottlerGuard },
  ],
})
export class AppModule {}
  1. 统一响应与错误码 src/common/constants/error-codes.ts
export enum ErrorCode {
  OK = 0,
  VALIDATION_FAILED = 1001,
  UNAUTHORIZED = 1002,
  FORBIDDEN = 1003,
  NOT_FOUND = 1004,
  RATE_LIMITED = 1005,
  CONFLICT = 1006,
  INTERNAL_ERROR = 1999,
}

src/common/interceptors/response.interceptor.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ErrorCode } from '../constants/error-codes';
import { getTraceId } from '../utils/trace.util';

@Injectable()
export class ResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    const traceId = getTraceId(req);
    return next.handle().pipe(
      map((data) => ({
        code: ErrorCode.OK,
        message: 'success',
        data,
        traceId,
      })),
    );
  }
}

src/common/filters/http-exception.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode } from '../constants/error-codes';
import { getTraceId } from '../utils/trace.util';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse();
    const req = ctx.getRequest();

    const traceId = getTraceId(req);
    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal Server Error';
    let code = ErrorCode.INTERNAL_ERROR;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const response: any = exception.getResponse();
      message = response?.message || exception.message;
      code = this.mapStatusToCode(status);
    }

    res.status(status).json({ code, message, traceId });
  }

  private mapStatusToCode(status: number): ErrorCode {
    switch (status) {
      case 400: return ErrorCode.VALIDATION_FAILED;
      case 401: return ErrorCode.UNAUTHORIZED;
      case 403: return ErrorCode.FORBIDDEN;
      case 404: return ErrorCode.NOT_FOUND;
      case 409: return ErrorCode.CONFLICT;
      case 429: return ErrorCode.RATE_LIMITED;
      default: return ErrorCode.INTERNAL_ERROR;
    }
  }
}

src/common/interceptors/audit.interceptor.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    const userId = req.user?.sub;
    const now = Date.now();
    return next.handle().pipe(
      tap(() => {
        // 简化版审计日志,可替换为写入DB或队列
        // eslint-disable-next-line no-console
        console.log('[AUDIT]', {
          userId,
          method: req.method,
          path: req.originalUrl || req.url,
          ip: req.ip,
          durationMs: Date.now() - now,
        });
      }),
    );
  }
}

src/common/interceptors/metrics.interceptor.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Counter, Histogram, register } from 'prom-client';

const httpRequestsTotal = new Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'route', 'status'],
});

const httpRequestDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.05, 0.1, 0.3, 0.5, 1, 3, 5],
});

register.registerMetric(httpRequestsTotal);
register.registerMetric(httpRequestDuration);

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const method = request.method;
    const route = request.route?.path || request.url;
    const start = process.hrtime();

    return next.handle().pipe(
      tap({
        next: () => {},
        error: (err) => {
          const dur = process.hrtime(start);
          const seconds = dur[0] + dur[1] / 1e9;
          httpRequestsTotal.inc({ method, route, status: err?.status || 500 });
          httpRequestDuration.observe({ method, route, status: err?.status || 500 }, seconds);
        },
        complete: () => {
          const response = context.switchToHttp().getResponse();
          const status = response.statusCode;
          const dur = process.hrtime(start);
          const seconds = dur[0] + dur[1] / 1e9;
          httpRequestsTotal.inc({ method, route, status });
          httpRequestDuration.observe({ method, route, status }, seconds);
        },
      }),
    );
  }
}

src/common/pipes/validation.pipe.ts

import { BadRequestException, Injectable, ValidationPipe as NestValidationPipe } from '@nestjs/common';

@Injectable()
export class ValidationPipe extends NestValidationPipe {
  constructor() {
    super({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
      exceptionFactory: (errors) => new BadRequestException(errors.map(e => Object.values(e.constraints || {})).flat()),
    });
  }
}
  1. 安全:JWT 与角色守卫 src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { JwtAccessStrategy } from './strategies/jwt-access.strategy';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({ // 动态在 service 使用 secrets
      global: true,
      signOptions: { expiresIn: '15m' },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtAccessStrategy, JwtRefreshStrategy],
  exports: [AuthService],
})
export class AuthModule {}

src/modules/auth/auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';
import { ConfigService } from '@nestjs/config';
import { CacheService } from '../cache/cache.service';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
    private readonly cache: CacheService,
  ) {}

  async validateUser(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);
    if (!user) throw new UnauthorizedException('Invalid credentials');
    const ok = await bcrypt.compare(password, user.passwordHash);
    if (!ok) throw new UnauthorizedException('Invalid credentials');
    return user;
  }

  async issueTokens(user: { id: string; roles: string[] }, deviceId?: string) {
    const accessSecret = this.config.get<string>('security.accessTokenSecret');
    const refreshSecret = this.config.get<string>('security.refreshTokenSecret');
    const accessToken = await this.jwt.signAsync({ sub: user.id, roles: user.roles }, { secret: accessSecret, expiresIn: '15m' });
    const refreshToken = await this.jwt.signAsync({ sub: user.id, deviceId }, { secret: refreshSecret, expiresIn: '7d' });

    if (deviceId) {
      await this.cache.storeRefreshToken(user.id, deviceId, refreshToken);
    }
    return { accessToken, refreshToken };
  }

  async refresh(userId: string, deviceId: string, token: string) {
    const stored = await this.cache.getRefreshToken(userId, deviceId);
    if (!stored || stored !== token) throw new UnauthorizedException('Invalid refresh token');

    const user = await this.usersService.findById(userId);
    return this.issueTokens({ id: user.id, roles: user.roles }, deviceId);
  }

  async revoke(userId: string, deviceId: string) {
    await this.cache.deleteRefreshToken(userId, deviceId);
    return true;
  }
}

src/modules/auth/auth.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dtos/login.dto';
import { RefreshDto } from './dtos/refresh.dto';

@Controller({ path: 'auth', version: '1' })
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('login')
  async login(@Body() dto: LoginDto) {
    const user = await this.authService.validateUser(dto.email, dto.password);
    return this.authService.issueTokens({ id: user.id, roles: user.roles }, dto.deviceId);
  }

  @Post('refresh')
  async refresh(@Body() dto: RefreshDto) {
    return this.authService.refresh(dto.userId, dto.deviceId, dto.refreshToken);
  }

  @Post('logout')
  async logout(@Body() dto: RefreshDto) {
    await this.authService.revoke(dto.userId, dto.deviceId);
    return { ok: true };
  }
}

src/modules/auth/strategies/jwt-access.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.get<string>('security.accessTokenSecret'),
      ignoreExpiration: false,
    });
  }
  async validate(payload: any) {
    return payload; // { sub, roles }
  }
}

src/modules/auth/strategies/jwt-refresh.strategy.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: (req) => req.body?.refreshToken,
      secretOrKey: config.get<string>('security.refreshTokenSecret'),
      ignoreExpiration: false,
    });
  }
  async validate(payload: any) {
    if (!payload?.sub) throw new UnauthorizedException();
    return payload; // { sub, deviceId }
  }
}

src/common/guards/jwt-auth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

src/common/decorators/roles.decorator.ts

import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

src/common/guards/roles.guard.ts

import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.get<string[]>(ROLES_KEY, context.getHandler());
    if (!required || required.length === 0) return true;
    const user = context.switchToHttp().getRequest().user;
    const ok = required.some(r => user?.roles?.includes(r));
    if (!ok) throw new ForbiddenException('Insufficient role');
    return true;
  }
}
  1. CORS 与速率限制 src/config/cors.ts
import { INestApplication } from '@nestjs/common';
export function setupCors(app: INestApplication) {
  app.enableCors({
    origin: (origin, cb) => cb(null, true),
    credentials: true,
    exposedHeaders: ['x-trace-id'],
  });
}

src/config/throttler.ts

import { INestApplication } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';

export function setupThrottler(app: INestApplication) {
  // 已在 AppModule 配置 APP_GUARD
}

export const ThrottlerImport = ThrottlerModule.forRoot({
  ttl: 60,
  limit: 100,
});
  1. Swagger 文档 src/config/swagger.ts
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

export function setupSwagger(app: INestApplication) {
  const config = new DocumentBuilder()
    .setTitle('BFF Gateway API')
    .setDescription('Web/Mobile BFF REST API with JWT, RBAC, Cache, Queue and Observability')
    .setVersion('1.0.0')
    .addBearerAuth()
    .build();

  const doc = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('/docs', app, doc, {
    swaggerOptions: { persistAuthorization: true },
  });
}
  1. 配置与校验 src/config/configuration.ts
export default () => ({
  nodeEnv: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT || '3000', 10),
  security: {
    accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || 'change_me',
    refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || 'change_me_too',
  },
  db: {
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT || '5432', 10),
    user: process.env.DB_USER || 'postgres',
    password: process.env.DB_PASSWORD || 'postgres',
    database: process.env.DB_NAME || 'bff',
  },
  redis: {
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  },
  queue: {
    prefix: 'bff',
  },
});

src/config/validation.ts

import * as Joi from 'joi';

export function validateEnv(config: Record<string, any>) {
  const schema = Joi.object({
    NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
    PORT: Joi.number().default(3000),
    ACCESS_TOKEN_SECRET: Joi.string().min(16).required(),
    REFRESH_TOKEN_SECRET: Joi.string().min(16).required(),
    DB_HOST: Joi.string().required(),
    DB_PORT: Joi.number().default(5432),
    DB_USER: Joi.string().required(),
    DB_PASSWORD: Joi.string().allow(''),
    DB_NAME: Joi.string().required(),
    REDIS_URL: Joi.string().uri().required(),
  }).unknown(true);

  const { error } = schema.validate(process.env);
  if (error) throw new Error(`Config validation error: ${error.message}`);
  return config;
}
  1. 数据库与实体/迁移/种子 src/database/typeorm.config.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { User } from '../modules/users/entities/user.entity';
import { Product } from '../modules/products/entities/product.entity';
import { Order } from '../modules/orders/entities/order.entity';

export const typeOrmConfig = (config: ConfigService): TypeOrmModuleOptions => ({
  type: 'postgres',
  host: config.get('db.host'),
  port: config.get('db.port'),
  username: config.get('db.user'),
  password: config.get('db.password'),
  database: config.get('db.database'),
  entities: [User, Product, Order],
  migrations: ['dist/database/migrations/*.js'],
  synchronize: false,
  logging: ['error', 'warn'],
});

src/database/database.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { typeOrmConfig } from './typeorm.config';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => typeOrmConfig(config),
    }),
  ],
})
export class DatabaseModule {}

src/modules/users/entities/user.entity.ts

import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @Column('text', { array: true, default: '{}' })
  roles: string[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

src/modules/products/entities/product.entity.ts

import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

@Entity('products')
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column('numeric', { precision: 12, scale: 2 })
  price: string;

  @Column({ default: true })
  active: boolean;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

src/modules/orders/entities/order.entity.ts

import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';

@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @ManyToOne(() => User)
  user: User;

  @Column('jsonb')
  items: Array<{ productId: string; quantity: number; price: string }>;

  @Column('numeric', { precision: 12, scale: 2 })
  total: string;

  @Column({ default: 'PENDING' })
  status: 'PENDING' | 'PAID' | 'CANCELLED';

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

迁移示例 src/database/migrations/1700000000000-InitSchema.ts

import { MigrationInterface, QueryRunner } from 'typeorm';

export class InitSchema1700000000000 implements MigrationInterface {
  name = 'InitSchema1700000000000';
  public async up(q: QueryRunner): Promise<void> {
    await q.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`);
    await q.query(`CREATE TABLE IF NOT EXISTS users (
      id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
      email varchar UNIQUE NOT NULL,
      password_hash varchar NOT NULL,
      roles text[] NOT NULL DEFAULT '{}',
      created_at timestamptz DEFAULT now(),
      updated_at timestamptz DEFAULT now()
    );`);
    await q.query(`CREATE TABLE IF NOT EXISTS products (
      id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
      name varchar NOT NULL,
      price numeric(12,2) NOT NULL,
      active boolean DEFAULT true,
      created_at timestamptz DEFAULT now(),
      updated_at timestamptz DEFAULT now()
    );`);
    await q.query(`CREATE TABLE IF NOT EXISTS orders (
      id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
      user_id uuid REFERENCES users(id),
      items jsonb NOT NULL,
      total numeric(12,2) NOT NULL,
      status varchar NOT NULL DEFAULT 'PENDING',
      created_at timestamptz DEFAULT now(),
      updated_at timestamptz DEFAULT now()
    );`);
  }
  public async down(q: QueryRunner): Promise<void> {
    await q.query(`DROP TABLE IF EXISTS orders;`);
    await q.query(`DROP TABLE IF EXISTS products;`);
    await q.query(`DROP TABLE IF EXISTS users;`);
  }
}

种子数据 src/database/seeds/factories/user.factory.ts

import { DataSource } from 'typeorm';
import { User } from '../../modules/users/entities/user.entity';
import * as bcrypt from 'bcryptjs';

export async function createAdmin(ds: DataSource) {
  const repo = ds.getRepository(User);
  const exists = await repo.findOne({ where: { email: 'admin@bff.local' } });
  if (exists) return exists;
  const passwordHash = await bcrypt.hash('Admin@123', 10);
  const admin = repo.create({ email: 'admin@bff.local', passwordHash, roles: ['admin'] });
  return repo.save(admin);
}

src/database/seeds/seed.ts

import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { createAdmin } from './factories/user.factory';
import { User } from '../../modules/users/entities/user.entity';
import { Product } from '../../modules/products/entities/product.entity';
import { Order } from '../../modules/orders/entities/order.entity';

const ds = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432', 10),
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: [User, Product, Order],
});

(async () => {
  await ds.initialize();
  await createAdmin(ds);
  await ds.getRepository(Product).save([
    { name: 'Sample A', price: '19.90', active: true },
    { name: 'Sample B', price: '29.90', active: true },
  ]);
  await ds.destroy();
  // eslint-disable-next-line no-console
  console.log('Seed completed');
})();
  1. 控制器/服务/DTO 与校验示例 src/modules/users/dtos/create-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';

export class CreateUserDto {
  @ApiProperty() @IsEmail() email: string;
  @ApiProperty() @MinLength(8) password: string;
  @ApiProperty({ isArray: true, required: false }) roles?: string[];
}

src/modules/users/users.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import * as bcrypt from 'bcryptjs';

@Injectable()
export class UsersService {
  constructor(@InjectRepository(User) private readonly repo: Repository<User>) {}

  async findByEmail(email: string) {
    return this.repo.findOne({ where: { email } });
  }
  async findById(id: string) {
    const u = await this.repo.findOne({ where: { id } });
    if (!u) throw new NotFoundException('User not found');
    return u;
  }
  async create(email: string, password: string, roles: string[] = ['user']) {
    const passwordHash = await bcrypt.hash(password, 10);
    const u = this.repo.create({ email, passwordHash, roles });
    return this.repo.save(u);
  }
}

src/modules/users/users.controller.ts

import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { UsersService } from './users.service';
import { CreateUserDto } from './dtos/create-user.dto';

@Controller({ path: 'users', version: '1' })
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Roles('admin')
  @Post()
  async create(@Body() dto: CreateUserDto) {
    return this.users.create(dto.email, dto.password, dto.roles || ['user']);
  }

  @Get(':id')
  async byId(@Param('id') id: string) {
    return this.users.findById(id);
  }
}

类似提供 products/orders 的模块(省略冗长代码),在 service 中可聚合调用其他后端服务(如通过 HTTP 客户端 axios),并结合 Redis 缓存与失效策略。

  1. 缓存与 Redis src/modules/cache/cache.module.ts
import { Module } from '@nestjs/common';
import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
import { CacheService } from './cache.service';
import { ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-redis-yet';

@Module({
  imports: [
    NestCacheModule.registerAsync({
      inject: [ConfigService],
      useFactory: async (config: ConfigService) => ({
        store: await redisStore({
          url: config.get<string>('redis.url'),
          ttl: 5, // 默认缓存 5s
        }),
      }),
      isGlobal: true,
    }),
  ],
  providers: [CacheService],
  exports: [CacheService],
})
export class CacheModule {}

src/modules/cache/cache.service.ts

import { Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';

@Injectable()
export class CacheService {
  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}

  async get<T>(key: string): Promise<T | undefined> {
    return this.cache.get<T>(key);
  }
  async set<T>(key: string, val: T, ttlSeconds?: number) {
    await this.cache.set(key, val, ttlSeconds ? { ttl: ttlSeconds } : undefined);
  }
  async del(key: string) {
    await this.cache.del(key);
  }

  async storeRefreshToken(userId: string, deviceId: string, token: string) {
    await this.set(`rt:${userId}:${deviceId}`, token, 7 * 24 * 3600);
  }
  async getRefreshToken(userId: string, deviceId: string) {
    return this.get<string>(`rt:${userId}:${deviceId}`);
  }
  async deleteRefreshToken(userId: string, deviceId: string) {
    await this.del(`rt:${userId}:${deviceId}`);
  }
}

src/common/decorators/cached.decorator.ts

import { SetMetadata } from '@nestjs/common';
export const CACHED_KEY = 'cached';
export const Cached = (key: string, ttl = 60) => SetMetadata(CACHED_KEY, { key, ttl });

可在拦截器中读取 CACHED_KEY 自动缓存(此处略),或在 service 手动缓存与失效。

  1. 队列与异步任务(延迟与重试) src/modules/queue/queue.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigService } from '@nestjs/config';
import { MailProcessor } from './mail.processor';
import { ReconcileProcessor } from './reconcile.processor';

@Module({
  imports: [
    BullModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        connection: { url: config.get<string>('redis.url') },
        prefix: config.get<string>('queue.prefix'),
      }),
    }),
    BullModule.registerQueue(
      { name: 'mail' },
      { name: 'reconcile' },
    ),
  ],
  providers: [MailProcessor, ReconcileProcessor],
  exports: [],
})
export class QueueModule {}

src/modules/queue/mail.processor.ts

import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';

@Processor('mail')
export class MailProcessor extends WorkerHost {
  async process(job: Job) {
    // 发送邮件逻辑(例如使用 nodemailer)
    // 支持重试与延迟由添加任务时配置
    // eslint-disable-next-line no-console
    console.log('Sending mail', job.id, job.data);
  }
}

src/modules/queue/reconcile.processor.ts

import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';

@Processor('reconcile')
export class ReconcileProcessor extends WorkerHost {
  async process(job: Job) {
    // 对账处理逻辑
    // eslint-disable-next-line no-console
    console.log('Reconciling', job.id, job.data);
  }
}

在业务 service 中添加任务示例:

// await this.queue.add('reconcile', { orderId }, { delay: 60_000, attempts: 5, backoff: { type: 'exponential', delay: 1000 } });
  1. 健康检查、指标与链路追踪 src/modules/health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
})
export class HealthModule {}

src/modules/health/health.controller.ts

import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(private health: HealthCheckService, private db: TypeOrmHealthIndicator) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

src/modules/metrics/metrics.module.ts

import { Module } from '@nestjs/common';
import { MetricsController } from './metrics.controller';

@Module({
  controllers: [MetricsController],
})
export class MetricsModule {}

src/modules/metrics/metrics.controller.ts

import { Controller, Get } from '@nestjs/common';
import { register } from 'prom-client';

@Controller('metrics')
export class MetricsController {
  @Get()
  async metrics() {
    return register.metrics();
  }
}

OpenTelemetry 初始化 src/tracing/tracing.ts

import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

let sdk: NodeSDK;

export function initTracing() {
  if (sdk) return;
  const exporter = new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
  });
  sdk = new NodeSDK({
    resource: new Resource({
      [SemanticResourceAttributes.SERVICE_NAME]: 'bff-gateway',
      [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
    }),
    traceExporter: exporter,
    instrumentations: [getNodeAutoInstrumentations()],
  });
  sdk.start();
}

src/common/utils/trace.util.ts

import { randomUUID } from 'crypto';
export function getTraceId(req: any): string {
  const id = req.headers['x-trace-id'] || req.id || randomUUID();
  req.headers['x-trace-id'] = id;
  return id;
}
  1. 测试(Jest 单元/集成,隔离测试数据库) jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  rootDir: '.',
  testMatch: ['**/test/**/*.spec.ts'],
  moduleFileExtensions: ['ts', 'js', 'json'],
  collectCoverage: true,
  coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
};

test/unit/users.service.spec.ts

import { UsersService } from '../../src/modules/users/users.service';
import { Repository } from 'typeorm';
import { User } from '../../src/modules/users/entities/user.entity';

const repo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as unknown as Repository<User>;

describe('UsersService', () => {
  it('create hashes password', async () => {
    const svc = new UsersService(repo);
    repo.create = jest.fn().mockReturnValue({ email: 'a@b.com', passwordHash: 'x', roles: ['user'] });
    repo.save = jest.fn().mockResolvedValue({ id: 'u1', email: 'a@b.com', roles: ['user'] });
    const res = await svc.create('a@b.com', 'Secret123', ['user']);
    expect(res.email).toBe('a@b.com');
    expect(repo.create).toHaveBeenCalled();
  });
});

test/e2e/app.e2e-spec.ts

import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../../src/app.module';
import * as request from 'supertest';

describe('App E2E', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('/health (GET)', async () => {
    const res = await request(app.getHttpServer()).get('/health').expect(200);
    expect(res.body.status).toBe('ok');
  });
});

测试数据库隔离建议使用 testcontainers(可按需增加),或在 CI 启动 docker-compose 的 postgres 与 redis。

  1. 环境与 .env 模板 .env.example
NODE_ENV=development
PORT=3000

ACCESS_TOKEN_SECRET=replace_with_long_random_string
REFRESH_TOKEN_SECRET=replace_with_long_random_string

DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=bff

REDIS_URL=redis://redis:6379

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
  1. 部署:Docker 与 Compose Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package.json /app/yarn.lock ./
RUN yarn install --frozen-lockfile --production
COPY --from=builder /app/dist ./dist
COPY .env ./.env
CMD ["node", "dist/main.js"]

docker-compose.yml

services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file: .env
    depends_on:
      - postgres
      - redis
    command: ["node", "dist/main.js"]
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: bff
    ports:
      - "5432:5432"
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
  otel-collector:
    image: otel/opentelemetry-collector:latest
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4318:4318"

scripts/start-prod.sh

#!/usr/bin/env sh
set -e
node dist/main.js
  1. CI/CD(GitHub Actions) .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  build-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: bff_test
        ports: ['5432:5432']
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
    env:
      NODE_ENV: test
      ACCESS_TOKEN_SECRET: test_access_secret_1234567890
      REFRESH_TOKEN_SECRET: test_refresh_secret_1234567890
      DB_HOST: localhost
      DB_PORT: 5432
      DB_USER: postgres
      DB_PASSWORD: postgres
      DB_NAME: bff_test
      REDIS_URL: redis://localhost:6379
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'yarn'
      - run: yarn install --frozen-lockfile
      - run: yarn lint
      - run: yarn build
      - run: yarn test --coverage

  docker:
    needs: build-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
  1. package.json 与脚本(yarn) package.json
{
  "name": "bff-gateway",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "nest start",
    "start:dev": "nest start --watch",
    "build": "nest build",
    "lint": "eslint . --ext .ts",
    "test": "jest",
    "test:e2e": "jest --config test/e2e/jest-e2e.json",
    "typeorm:migration:generate": "typeorm migration:generate ./src/database/migrations/AutoMigration -d ormconfig.ts",
    "typeorm:migration:run": "typeorm migration:run -d ormconfig.ts",
    "seed": "ts-node ./src/database/seeds/seed.ts",
    "docker:up": "docker-compose up -d --build",
    "docker:down": "docker-compose down"
  },
  "dependencies": {
    "@nestjs/bullmq": "^2.0.0",
    "@nestjs/cache-manager": "^2.2.0",
    "@nestjs/common": "^10.0.0",
    "@nestjs/config": "^3.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/jwt": "^10.2.0",
    "@nestjs/passport": "^10.0.0",
    "@nestjs/platform-express": "^10.0.0",
    "@nestjs/swagger": "^7.3.0",
    "@nestjs/terminus": "^10.2.0",
    "@nestjs/throttler": "^4.2.0",
    "bcryptjs": "^2.4.3",
    "bullmq": "^5.5.0",
    "cache-manager": "^5.2.0",
    "cache-manager-redis-yet": "^3.0.2",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.0",
    "ioredis": "^5.4.1",
    "passport": "^0.7.0",
    "passport-jwt": "^4.0.1",
    "pg": "^8.12.0",
    "prom-client": "^15.1.1",
    "reflect-metadata": "^0.1.13",
    "rxjs": "^7.8.1",
    "typeorm": "^0.3.20",
    "@opentelemetry/sdk-node": "^0.49.1",
    "@opentelemetry/auto-instrumentations-node": "^0.49.1",
    "@opentelemetry/exporter-trace-otlp-http": "^0.49.1"
  },
  "devDependencies": {
    "@nestjs/testing": "^10.0.0",
    "@types/bcryptjs": "^2.4.2",
    "@types/jest": "^29.5.4",
    "@types/node": "^20.8.10",
    "@types/passport-jwt": "^3.0.10",
    "@types/supertest": "^2.0.12",
    "eslint": "^8.57.0",
    "eslint-config-standard-with-typescript": "^38.0.0",
    "eslint-plugin-import": "^2.29.1",
    "eslint-plugin-n": "^16.3.1",
    "eslint-plugin-promise": "^6.1.1",
    "jest": "^29.7.0",
    "supertest": "^6.3.3",
    "ts-jest": "^29.1.1",
    "ts-node": "^10.9.2",
    "typescript": "^5.4.0"
  }
}

.eslintrc.js(Standard 风格)

module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  extends: [
    'standard-with-typescript'
  ],
  parserOptions: {
    project: './tsconfig.json'
  },
  rules: {
    '@typescript-eslint/explicit-function-return-type': 'off'
  }
};

ormconfig.ts(TypeORM CLI)

import { DataSource } from 'typeorm';
import { User } from './src/modules/users/entities/user.entity';
import { Product } from './src/modules/products/entities/product.entity';
import { Order } from './src/modules/orders/entities/order.entity';

export default new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432', 10),
  username: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres',
  database: process.env.DB_NAME || 'bff',
  entities: [User, Product, Order],
  migrations: ['dist/database/migrations/*.js'],
});
  1. BFF 聚合示例(订单创建聚合产品价格与用户) src/modules/orders/orders.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './entities/order.entity';
import { ProductsService } from '../products/products.service';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order) private repo: Repository<Order>,
    private products: ProductsService,
  ) {}

  async create(userId: string, items: Array<{ productId: string; quantity: number }>) {
    if (!items?.length) throw new BadRequestException('Items required');
    const priced = await Promise.all(items.map(async i => {
      const p = await this.products.getById(i.productId);
      return { ...i, price: p.price };
    }));
    const total = priced.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0).toFixed(2);
    const order = this.repo.create({ user: { id: userId } as any, items: priced, total, status: 'PENDING' });
    return this.repo.save(order);
  }
}
  1. Swagger 示例请求在 DTO 上使用 ApiProperty,控制器上提供示例,或使用 @ApiBody/@ApiResponse(略)。

运行指南

可扩展建议

  • 在 common/interceptors 中添加缓存拦截器读取 @Cached 元数据统一缓存控制。
  • 审计日志写入专用表或通过队列异步写入以免阻塞。
  • 将 OpenTelemetry 与 Prometheus 结合 Grafana 展示。
  • 对接外部用户、商品、订单服务时,在 modules 下新增 client 子模块(HTTP/gRPC),并在 BFF service 进行聚合与容错(重试、熔断)。

若需要我生成完整项目仓库的压缩包或根据你的域模型补全 products/orders 的 DTO/Controller/Service 代码,请告知。

以下为可直接初始化的多包脚手架,满足你提出的技术与功能要求。结构清晰、可扩展、离线优先,内置本地加密 SQLite/SQLCipher、全文检索、增量同步、自动更新、安全 IPC 与 CSP、崩溃恢复和日志采集,支持跨平台打包与差分更新。包管理工具为 npm,统一代码风格使用 Prettier。

目录结构

  • package.json(工作区)
  • tsconfig.base.json
  • .prettierrc
  • .gitignore
  • .npmrc
  • docs/
    • getting-started.md
    • plugin-dev.md
    • sync-adapter.md
    • release.md
  • packages/
    • main/(主进程)
      • package.json
      • tsconfig.json
      • src/
        • main.ts
        • app-bootstrap.ts
        • windows/mainWindow.ts
        • security/ipcWhitelist.ts
        • security/protocols.ts
        • logging/logger.ts
        • crash/crashReporter.ts
        • updater/autoUpdater.ts
        • config/settings.ts
        • config/env.ts
        • ipc/typedIpc.ts
        • ipc/handlers/
          • repo.ts
          • notes.ts
          • search.ts
          • sync.ts
          • attachments.ts
          • settings.ts
        • db/Database.ts
        • db/migrations/001_init.sql
        • db/indexer.ts
        • sync/SyncEngine.ts
        • sync/adapters/CloudSyncAdapter.ts
        • sync/adapters/WebDAVAdapter.ts
        • sync/adapters/S3Adapter.ts
        • crypto/keychain.ts
        • crypto/crypto.ts
        • fileSystem/repoManager.ts
        • utils/diffMerge.ts
    • preload/(预加载沙箱)
      • package.json
      • tsconfig.json
      • src/index.ts
      • src/bridge.ts
      • src/context.ts
    • renderer/(React UI)
      • package.json
      • tsconfig.json
      • vite.config.ts
      • index.html
      • src/
        • main.tsx
        • App.tsx
        • routes/EditorPage.tsx
        • routes/SearchPage.tsx
        • routes/SettingsPage.tsx
        • components/MarkdownEditor.tsx
        • components/MarkdownPreview.tsx
        • components/RepoTree.tsx
        • components/GlobalSearch.tsx
        • components/Toolbar.tsx
        • components/AttachmentPane.tsx
        • theme/ThemeProvider.tsx
        • shortcuts/useShortcuts.ts
        • state/store.ts
        • ipc/client.ts
        • csp/csp.ts
    • shared/(共享类型与工具)
      • package.json
      • tsconfig.json
      • src/types/ipc.ts
      • src/types/models.ts
      • src/plugin/PluginAPI.ts
      • src/crypto/Crypto.ts
      • src/util/Result.ts
      • src/util/uuid.ts
  • tests/
    • unit/
      • db.test.ts
      • sync.test.ts
      • merge.test.ts
    • e2e/
      • playwright.config.ts
      • editor.spec.ts

关键配置与脚本 根 package.json(npm workspaces) { "name": "kb-client", "private": true, "version": "0.1.0", "workspaces": [ "packages/main", "packages/preload", "packages/renderer", "packages/shared" ], "scripts": { "bootstrap": "npm run install:all", "install:all": "npm i", "build": "npm run build:main && npm run build:preload && npm run build:renderer", "build:main": "npm --workspace packages/main run build", "build:preload": "npm --workspace packages/preload run build", "build:renderer": "npm --workspace packages/renderer run build", "dev": "cross-env APP_ENV=dev concurrently -n MAIN,PRELOAD,RENDERER "npm:dev:main" "npm:dev:preload" "npm:dev:renderer"", "dev:main": "npm --workspace packages/main run dev", "dev:preload": "npm --workspace packages/preload run dev", "dev:renderer": "npm --workspace packages/renderer run dev", "start": "npm run build && npm --workspace packages/main run start", "pack": "npm --workspace packages/main run pack", "publish:stable": "cross-env APP_UPDATE_CHANNEL=stable npm --workspace packages/main run publish", "publish:beta": "cross-env APP_UPDATE_CHANNEL=beta npm --workspace packages/main run publish", "test": "vitest run", "test:e2e": "playwright test" }, "devDependencies": { "concurrently": "^8.2.0", "cross-env": "^7.0.3", "vitest": "^1.3.1", "@playwright/test": "^1.48.0", "prettier": "^3.2.5" } }

tsconfig.base.json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Node", "lib": ["ES2022", "DOM"], "strict": true, "jsx": "react-jsx", "baseUrl": ".", "paths": { "@shared/": ["packages/shared/src/"] } } }

.prettierrc { "semi": true, "singleQuote": true, "printWidth": 100, "trailingComma": "es5" }

安全与主进程样板 packages/main/src/main.ts import { app, BrowserWindow, ipcMain, protocol } from 'electron'; import path from 'node:path'; import { setupCrashReporter } from './crash/crashReporter'; import { setupLogger, logger } from './logging/logger'; import { registerIpcHandlers } from './ipc/typedIpc'; import { createMainWindow } from './windows/mainWindow'; import { setupAutoUpdater } from './updater/autoUpdater'; import { registerSecureProtocols } from './security/protocols';

if (!app.requestSingleInstanceLock()) { app.quit(); }

app.setName('KB Client'); setupLogger(); setupCrashReporter();

app.on('ready', async () => { registerSecureProtocols(protocol); const win = await createMainWindow();

registerIpcHandlers(ipcMain); setupAutoUpdater();

logger.info('App ready'); });

app.on('second-instance', () => { const all = BrowserWindow.getAllWindows(); if (all[0]) all[0].focus(); });

packages/main/src/windows/mainWindow.ts import { BrowserWindow } from 'electron'; import path from 'node:path';

export async function createMainWindow() { const win = new BrowserWindow({ width: 1200, height: 800, minWidth: 980, minHeight: 600, show: false, webPreferences: { preload: path.join(__dirname, '../../preload/index.js'), nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, devTools: process.env.APP_ENV !== 'prod', disableBlinkFeatures: 'Auxclick', }, });

const url = process.env.APP_ENV === 'dev' ? 'http://localhost:5173' : file://${path.join(__dirname, '../../renderer/index.html')};

await win.loadURL(url); win.once('ready-to-show', () => win.show());

return win; }

严格 IPC 通道与白名单 packages/shared/src/types/ipc.ts export const IPC_CHANNELS = { REPO: { OPEN: 'repo:open', RECENT: 'repo:recent', }, NOTES: { LIST: 'notes:list', GET: 'notes:get', UPSERT: 'notes:upsert', DELETE: 'notes:delete', }, SEARCH: { QUERY: 'search:query', REINDEX: 'search:reindex', }, SYNC: { STATUS: 'sync:status', RUN: 'sync:run', CONFIGURE: 'sync:configure', }, ATTACHMENTS: { LIST: 'attachments:list', ADD: 'attachments:add', REMOVE: 'attachments:remove', }, SETTINGS: { GET: 'settings:get', SET: 'settings:set', }, } as const;

export type IpcRequest = | { channel: typeof IPC_CHANNELS.REPO.OPEN; payload: { path?: string } } | { channel: typeof IPC_CHANNELS.NOTES.LIST; payload: { query?: string; tag?: string } } // ...继续补全类型定义

packages/main/src/security/ipcWhitelist.ts import { IPC_CHANNELS } from '@shared/types/ipc';

const allowed = new Set( Object.values(IPC_CHANNELS) .flatMap((g: any) => Object.values(g)) .map(String) );

export function isAllowedChannel(channel: string) { return allowed.has(channel); }

packages/preload/src/bridge.ts import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from '@shared/types/ipc'; import { isAllowedChannel } from '../../main/src/security/ipcWhitelist'; // 可改为共享包或直接导入常量

function safeInvoke(channel: string, payload: unknown) { if (!Object.values(IPC_CHANNELS).some((g: any) => Object.values(g).includes(channel))) { throw new Error(Blocked IPC channel: ${channel}); } return ipcRenderer.invoke(channel, payload); }

contextBridge.exposeInMainWorld('api', { invoke: safeInvoke, on: (channel: string, listener: (event: any, ...args: any[]) => void) => { if (!isAllowedChannel(channel)) throw new Error(Blocked IPC channel: ${channel}); ipcRenderer.on(channel, listener); }, });

内容安全策略 packages/renderer/index.html

KB Client

数据层:加密 SQLite/SQLCipher 封装与全文索引 packages/main/src/db/Database.ts import Database from 'better-sqlite3'; import path from 'node:path'; import fs from 'node:fs'; import { getSettings } from '../config/settings'; import { logger } from '../logging/logger';

export class EncryptedDb { private db: Database.Database;

constructor(private filePath: string, private key: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); this.db = new Database(filePath); // 注意:生产环境需使用 SQLCipher 编译的 better-sqlite3 this.db.pragma(key='${this.key}'); this.db.pragma('journal_mode=WAL'); this.db.pragma('synchronous=NORMAL'); this.ensureSchema(); }

ensureSchema() { this.db.exec(PRAGMA user_version = 1; CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, tags TEXT, backlinks TEXT, created_at INTEGER, updated_at INTEGER, deleted INTEGER DEFAULT 0 ); CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( title, content, tags, content='notes', content_rowid='rowid' ); CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, note_id TEXT, name TEXT, path TEXT, size INTEGER, mime TEXT, created_at INTEGER ); CREATE TABLE IF NOT EXISTS sync_state ( entity_id TEXT PRIMARY KEY, version INTEGER, last_hash TEXT, last_modified INTEGER ); CREATE TABLE IF NOT EXISTS history ( id TEXT PRIMARY KEY, entity_id TEXT, snapshot TEXT, created_at INTEGER );); this.db.exec(CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN INSERT INTO notes_fts(rowid, title, content, tags) VALUES (new.rowid, new.title, new.content, new.tags); END; CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN INSERT INTO notes_fts(notes_fts, rowid, title, content, tags) VALUES ('delete', old.rowid, old.title, old.content, old.tags); INSERT INTO notes_fts(rowid, title, content, tags) VALUES (new.rowid, new.title, new.content, new.tags); END; CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN INSERT INTO notes_fts(notes_fts, rowid, title, content, tags) VALUES ('delete', old.rowid, old.title, old.content, old.tags); END;); }

listNotes(query?: { tag?: string; search?: string }) { if (query?.search) { const stmt = this.db.prepare( SELECT n.* FROM notes n JOIN notes_fts f ON f.rowid = n.rowid WHERE notes_fts MATCH ? AND n.deleted = 0 ORDER BY updated_at DESC ); return stmt.all(query.search); } if (query?.tag) { const stmt = this.db.prepare( SELECT * FROM notes WHERE tags LIKE ? AND deleted = 0 ORDER BY updated_at DESC ); return stmt.all(%${query.tag}%); } return this.db.prepare(SELECT * FROM notes WHERE deleted = 0 ORDER BY updated_at DESC).all(); }

upsertNote(note: any) { const stmt = this.db.prepare( INSERT INTO notes(id, title, content, tags, backlinks, created_at, updated_at, deleted) VALUES(@id, @title, @content, @tags, @backlinks, @created_at, @updated_at, @deleted) ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, tags=excluded.tags, backlinks=excluded.backlinks, updated_at=excluded.updated_at, deleted=excluded.deleted ); const history = this.db.prepare( INSERT INTO history(id, entity_id, snapshot, created_at) VALUES(@hid, @entity_id, @snapshot, @created_at) ); const tx = this.db.transaction((n: any) => { stmt.run(n); history.run({ hid: n.id + ':' + Date.now(), entity_id: n.id, snapshot: JSON.stringify(n), created_at: Date.now(), }); }); tx(note); }

rollback(entityId: string, historyId: string) { const h = this.db.prepare(SELECT snapshot FROM history WHERE id=? AND entity_id=?).get(historyId, entityId); if (!h) return false; const n = JSON.parse(h.snapshot); this.upsertNote(n); return true; } }

全文检索增量扫描器 packages/main/src/db/indexer.ts import { EncryptedDb } from './Database'; import fg from 'fast-glob'; import fs from 'node:fs/promises'; import path from 'node:path'; import { logger } from '../logging/logger';

export async function incrementalScan(repoPath: string, db: EncryptedDb) { const files = await fg(['**/.md'], { cwd: repoPath, dot: false }); for (const file of files) { const full = path.join(repoPath, file); const content = await fs.readFile(full, 'utf-8'); // 简单解析 Markdown 标题、标签(约定在 YAML FrontMatter 或 #tags) const title = content.split('\n')[0].replace(/^#\s/, '').slice(0, 120); const tags = extractTags(content).join(','); db.upsertNote({ id: pathToId(file), title, content, tags, backlinks: JSON.stringify([]), created_at: Date.now(), updated_at: Date.now(), deleted: 0, }); } logger.info(Indexed ${files.length} markdown files); }

function extractTags(content: string) { const tags = new Set(); const tagPattern = /#([a-zA-Z0-9/_-]+)/g; let m; while ((m = tagPattern.exec(content))) tags.add(m[1]); return [...tags]; } function pathToId(rel: string) { return rel.replace(/[\/]/g, ':'); }

端到端加密与增量同步策略 packages/main/src/sync/adapters/CloudSyncAdapter.ts export interface EncryptedBlob { id: string; version: number; payload: Uint8Array; // 已加密 meta: { mime: string; size: number; modified: number }; }

export interface CloudSyncAdapter { listSince(cursor: string): Promise<{ items: EncryptedBlob[]; cursor: string }>; download(id: string): Promise<EncryptedBlob | null>; upload(blob: EncryptedBlob): Promise; delete(id: string): Promise; }

packages/main/src/sync/SyncEngine.ts import { EncryptedDb } from '../db/Database'; import { CloudSyncAdapter } from './adapters/CloudSyncAdapter'; import { encryptBlob, decryptBlob } from '../crypto/crypto'; import { logger } from '../logging/logger'; import { threeWayMerge } from '../utils/diffMerge';

export class SyncEngine { constructor(private db: EncryptedDb, private adapter: CloudSyncAdapter, private key: Uint8Array) {}

async runIncremental(cursor: string) { const { items, cursor: next } = await this.adapter.listSince(cursor); for (const item of items) { const data = decryptBlob(item.payload, this.key); // 解析实体类型:note/attachment const obj = JSON.parse(Buffer.from(data).toString('utf-8')); const local = this.db.listNotes({}).find((n: any) => n.id === obj.id); if (!local) { this.db.upsertNote(obj); } else { const merged = threeWayMerge(local.content, obj.baseContent, obj.content); this.db.upsertNote({ ...local, content: merged, updated_at: Date.now() }); } } logger.info(Sync applied ${items.length} changes); return next; }

async pushLocalChanges(changed: any[]) { for (const change of changed) { const payload = Buffer.from(JSON.stringify(change)); const enc = encryptBlob(payload, this.key); await this.adapter.upload({ id: change.id, version: change.version ?? Date.now(), payload: enc, meta: { mime: 'application/json', size: enc.byteLength, modified: Date.now() }, }); } logger.info(Pushed ${changed.length} changes); } }

packages/main/src/utils/diffMerge.ts // 简化的三方合并,生产可替换为成熟库(如 diff3) export function threeWayMerge(local: string, base: string, remote: string) { if (local === remote) return local; if (local === base) return remote; if (remote === base) return local; // 简单拼接保底 return [local, '\n', '<<<<<<< remote', '\n', remote].join(''); }

WebDAV 与 S3 适配示例 packages/main/src/sync/adapters/WebDAVAdapter.ts import { CloudSyncAdapter, EncryptedBlob } from './CloudSyncAdapter'; import { createClient } from 'webdav';

export class WebDAVAdapter implements CloudSyncAdapter { private client = createClient(process.env.SYNC_ENDPOINT!, { username: process.env.SYNC_USER!, password: process.env.SYNC_PASS!, });

async listSince(cursor: string) { const items = await this.client.getDirectoryContents('/kb'); // 简化,实际应基于 ETag/mtime 与 cursor const blobs: EncryptedBlob[] = []; return { items: blobs, cursor: Date.now().toString() }; } async download(id: string) { const buf = await this.client.getFileContents(/kb/${id}.bin, { format: 'binary' }); return { id, version: Date.now(), payload: new Uint8Array(buf as ArrayBuffer), meta: { mime: 'application/octet-stream', size: (buf as ArrayBuffer).byteLength, modified: Date.now() } }; } async upload(blob: EncryptedBlob) { await this.client.putFileContents(/kb/${blob.id}.bin, Buffer.from(blob.payload), { overwrite: true }); } async delete(id: string) { await this.client.deleteFile(/kb/${id}.bin); } }

packages/main/src/sync/adapters/S3Adapter.ts import { CloudSyncAdapter, EncryptedBlob } from './CloudSyncAdapter'; import { S3Client, ListObjectsV2Command, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';

export class S3Adapter implements CloudSyncAdapter { private client = new S3Client({ region: process.env.AWS_REGION });

async listSince(cursor: string) { const out = await this.client.send(new ListObjectsV2Command({ Bucket: process.env.S3_BUCKET!, Prefix: 'kb/' })); const items: EncryptedBlob[] = []; return { items, cursor: out.NextContinuationToken || '' }; } async download(id: string) { const obj = await this.client.send(new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: kb/${id}.bin })); const buf = await obj.Body?.transformToByteArray(); if (!buf) return null; return { id, version: Date.now(), payload: new Uint8Array(buf), meta: { mime: 'application/octet-stream', size: buf.length, modified: Date.now() } }; } async upload(blob: EncryptedBlob) { await this.client.send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: kb/${blob.id}.bin, Body: Buffer.from(blob.payload), ContentType: 'application/octet-stream' })); } async delete(id: string) { await this.client.send(new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: kb/${id}.bin })); } }

密钥管理与端到端加密 packages/main/src/crypto/keychain.ts import keytar from 'keytar'; const SERVICE = 'kb-client'; export async function getOrCreateKey(account: string) { const existing = await keytar.getPassword(SERVICE, account); if (existing) return Buffer.from(existing, 'hex'); const random = crypto.getRandomValues(new Uint8Array(32)); const hex = Buffer.from(random).toString('hex'); await keytar.setPassword(SERVICE, account, hex); return Buffer.from(hex, 'hex'); }

packages/shared/src/crypto/Crypto.ts export function encryptBlob(plain: Uint8Array, key: Uint8Array) { const iv = crypto.getRandomValues(new Uint8Array(12)); const algo = { name: 'AES-GCM', iv }; // 浏览器 WebCrypto 示例;Node 中请使用 SubtleCrypto 或 libsodium return plain; // 样例,生产需实现真正加密 } export function decryptBlob(enc: Uint8Array, key: Uint8Array) { return enc; // 样例,生产需实现真正解密 }

渲染进程 UI 样板 packages/renderer/src/main.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { ThemeProvider } from './theme/ThemeProvider'; import './csp/csp';

ReactDOM.createRoot(document.getElementById('root')!).render( );

packages/renderer/src/App.tsx import { useEffect } from 'react'; import { RepoTree } from './components/RepoTree'; import { MarkdownEditor } from './components/MarkdownEditor'; import { MarkdownPreview } from './components/MarkdownPreview'; import { GlobalSearch } from './components/GlobalSearch'; import { Toolbar } from './components/Toolbar'; import { useShortcuts } from './shortcuts/useShortcuts';

export default function App() { useShortcuts(); useEffect(() => { window.api.invoke('repo:recent', {}); }, []); return (

); }

packages/renderer/src/components/MarkdownEditor.tsx import { useState } from 'react'; import MDEditor from '@uiw/react-md-editor';

export function MarkdownEditor() { const [value, setValue] = useState('# 欢迎使用 KB Client'); return <MDEditor value={value} onChange={(v) => setValue(v || '')} />; }

packages/renderer/src/components/MarkdownPreview.tsx import ReactMarkdown from 'react-markdown'; export function MarkdownPreview({ content = '' }: { content?: string }) { return {content}; }

packages/renderer/src/components/RepoTree.tsx export function RepoTree() { return

目录树(后续通过 IPC 从主进程获取仓库文件列表)
; }

packages/renderer/src/components/GlobalSearch.tsx import { useState } from 'react'; export function GlobalSearch() { const [q, setQ] = useState(''); return (

<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="全文检索..." /> <button onClick={() => window.api.invoke('search:query', { q })}>搜索
); }

快捷键与沉浸式写作 packages/renderer/src/shortcuts/useShortcuts.ts import { useEffect } from 'react'; export function useShortcuts() { useEffect(() => { const handler = (e: KeyboardEvent) => { // 示例:Ctrl/Cmd+B 触发沉浸式模式 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') { document.body.classList.toggle('focus-mode'); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); }

IPC 客户端 packages/renderer/src/ipc/client.ts export async function invoke<T = any>(channel: string, payload?: any): Promise { // 由 preload 暴露的安全 API // @ts-ignore return window.api.invoke(channel, payload); }

自动更新与签名(electron-builder) packages/main/package.json { "name": "@kb/main", "version": "0.1.0", "main": "dist/main.js", "type": "module", "scripts": { "dev": "ts-node-esm src/main.ts", "build": "tsup src/main.ts --format cjs --out-dir dist --minify", "start": "electron .", "pack": "electron-builder --dir", "publish": "electron-builder --publish always" }, "dependencies": { "electron": "28.3.3", "electron-updater": "^6.1.7", "better-sqlite3": "^9.4.0", "fast-glob": "^3.3.2", "keytar": "^7.9.0", "electron-log": "^5.0.2", "@aws-sdk/client-s3": "^3.609.0", "webdav": "^5.4.0" }, "devDependencies": { "electron-builder": "^24.6.4", "tsup": "^8.0.1", "ts-node": "^10.9.2", "typescript": "^5.6.3" }, "build": { "appId": "com.example.kbclient", "productName": "KB Client", "files": [ "dist/", "../preload/dist/", "../renderer/dist/", "../shared/dist/" ], "extraResources": [ { "from": "resources/", "to": "resources", "filter": ["**/*"] } ], "mac": { "category": "public.app-category.productivity", "target": ["dmg", "zip"], "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "sign": true }, "win": { "target": ["nsis"], "publisherName": "Your Company", "artifactName": "${productName}-${version}-${os}-${arch}.${ext} " }, "linux": { "target": ["AppImage", "deb"], "artifactName": "${productName}-${version}-${os}-${arch}.${ext}" }, "publish": [ { "provider": "generic", "url": "https://update.example.com/${env.APP_UPDATE_CHANNEL}/" } ] } }

自动更新初始化 packages/main/src/updater/autoUpdater.ts import { autoUpdater } from 'electron-updater'; import { logger } from '../logging/logger';

export function setupAutoUpdater() { autoUpdater.autoDownload = true; autoUpdater.allowPrerelease = process.env.APP_UPDATE_CHANNEL === 'beta'; autoUpdater.on('update-available', (i) => logger.info(Update available: ${i.version})); autoUpdater.on('update-downloaded', () => autoUpdater.quitAndInstall(false, true)); autoUpdater.on('error', (e) => logger.error(Updater error: ${e})); autoUpdater.checkForUpdates(); }

崩溃恢复与日志采集 packages/main/src/crash/crashReporter.ts import { crashReporter } from 'electron'; export function setupCrashReporter() { crashReporter.start({ companyName: 'Your Company', productName: 'KB Client', submitURL: 'https://crash.example.com', uploadToServer: true, compress: true, ignoreSystemCrashHandler: true, }); }

packages/main/src/logging/logger.ts import log from 'electron-log'; export const logger = log.create('kb'); export function setupLogger() { log.initialize(); logger.transports.file.resolvePath = () => require('path').join(process.env.HOME || process.cwd(), '.kb-client/logs/main.log'); }

开发/调试/发布脚本

  • npm run dev:并行启动 main(ts-node)、preload(tsup watch)、renderer(Vite)
  • npm run pack:本地构建包(dir)
  • npm run publish:stable / publish:beta:区分稳定/测试渠道,差分更新传至 generic server
  • npm run test / test:e2e:单测与端到端测试
  • npm run start:构建后用 Electron 运行产物

渲染与预加载包 packages/preload/package.json { "name": "@kb/preload", "version": "0.1.0", "main": "dist/index.js", "type": "module", "scripts": { "dev": "tsup src/index.ts --watch --format cjs --out-dir dist", "build": "tsup src/index.ts --format cjs --out-dir dist --minify" }, "devDependencies": { "tsup": "^8.0.1", "typescript": "^5.6.3" } }

packages/preload/src/index.ts import './bridge';

packages/renderer/package.json { "name": "@kb/renderer", "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", "@uiw/react-md-editor": "^4.0.7", "react-markdown": "^9.0.1" }, "devDependencies": { "vite": "^5.0.12", "@vitejs/plugin-react": "^4.2.0", "typescript": "^5.6.3" } }

packages/renderer/vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], build: { outDir: 'dist', }, server: { port: 5173, strictPort: true, cors: false, } });

共享包与插件扩展接口 packages/shared/src/types/models.ts export interface Note { id: string; title: string; content: string; tags: string[]; backlinks: string[]; created_at: number; updated_at: number; deleted: 0 | 1; }

packages/shared/src/plugin/PluginAPI.ts export interface RendererPlugin { id: string; name: string; onMarkdownRender?(md: string): string; contributeToolbar?(ctx: any): JSX.Element; } export interface MainPlugin { id: string; onNoteSave?(note: any): Promise; onSyncPush?(changes: any[]): Promise; } export const pluginRegistry: { renderer: RendererPlugin[]; main: MainPlugin[] } = { renderer: [], main: [], };

环境变量管理与应用设置持久化 packages/main/src/config/env.ts import dotenv from 'dotenv'; dotenv.config(); export const env = { APP_ENV: process.env.APP_ENV || 'prod', APP_UPDATE_CHANNEL: process.env.APP_UPDATE_CHANNEL || 'stable', SYNC_PROVIDER: process.env.SYNC_PROVIDER || 'webdav', SYNC_ENDPOINT: process.env.SYNC_ENDPOINT || '', };

packages/main/src/config/settings.ts import Store from 'electron-store'; type Settings = { theme: 'light' | 'dark' | 'system'; recentRepo?: string; sync: { provider: string; endpoint: string; user?: string }; }; const store = new Store({ name: 'kb-settings', fileExtension: 'json' }); export function getSettings() { return store.store; } export function setSettings(patch: Partial) { store.store = { ...store.store, ...patch }; }

测试样板 tests/unit/db.test.ts import { describe, it, expect } from 'vitest'; describe('Database', () => { it('should upsert and search notes', () => { expect(true).toBe(true); }); });

tests/e2e/editor.spec.ts import { test, expect } from '@playwright/test'; test('can open editor and type markdown', async ({ page }) => { // 运行时需将 Electron 渲染进程暴露到 playwright;此处示例 expect(true).toBeTruthy(); });

发布渠道与差分更新适配

  • electron-builder 发布到 generic server,按 APP_UPDATE_CHANNEL 区分 stable/beta 目录
  • Windows 使用 NSIS,macOS 使用 DMG,Linux 使用 AppImage/Deb
  • 差分更新基于 electron-builder Blockmap 自动生成
  • docs/release.md 提供自建更新服务与 WebDAV/S3 静态托管指南

使用指南(摘要)

  • npm run bootstrap 安装依赖
  • npm run dev 启动开发(Vite + ts-node + tsup)
  • 首次启动会提示选择仓库目录,随后自动索引 Markdown 文件并持久化到加密数据库
  • 支持预览、全文检索、标签与双向链接(样板已留接口,生产完善解析与渲染)
  • 设置页可配置同步适配器与更新渠道
  • 插件可通过 shared 包的 PluginAPI 注册扩展渲染工具栏与保存钩子

安全说明

  • 渲染进程禁用 nodeIntegration,启用 contextIsolation 与 sandbox
  • 采用严格 IPC 白名单与类型约束
  • CSP 限制资源来源,禁用 object-src 与外部嵌入
  • 自定义协议通过 registerSecureProtocols 限制访问,避免任意文件读取

后续完善建议

  • 将 Crypto.ts 替换为真实 AES-GCM/NaCl 实现,主/渲染分别使用合适加密库
  • SQLCipher 需在 CI 构建时编译 better-sqlite3,并验证 pragma key 生效
  • 双向链接图谱、标签管理与附件缩略图生成
  • 冲突合并可引入成熟 diff3 算法与 Markdown 结构感知合并
  • 渲染 e2e 测试通过 electron-playwright 启动真实应用窗口进行操作
  • 云同步的 cursor 实现应基于版本向量或时序日志

该样板代码已覆盖主进程、预加载、渲染与共享包,提供安全、数据、同步、UI、构建、脚本、测试、配置、发布与文档的基础结构。可直接用于团队的离线优先知识库桌面客户端项目起步。

示例详情

解决的问题

为开发者提供高效的脚手架代码生成工具,快速搭建项目基础架构,简化开发流程,提升工程效率。

适用用户

初创公司开发者

需要快速启动产品开发,使用提示词一键生成标准化项目结构和基础代码,为产品验证赢得更多时间。

软件开发团队负责人

通过提示词提供的统一模板生成代码,规范团队工作流程,保障团队开发输出的高一致性与高质量。

自由职业软件工程师

在承接多样化项目时,通过提示词高效生成各类语言与框架的样板代码,从而提高接单效率并专注于业务逻辑设计。

特征总结

快速生成项目的样板代码,节省开发者搭建初始结构的时间。
支持多种编程语言与框架,无缝适配主流技术栈。
根据指定项目类型与需求,智能定制基础代码结构。
自动包含必要的核心元素与基础功能,全面提升代码质量。
为特定应用场景优化代码设计,确保项目目标快速落地。
按需灵活调整模板参数,让生成内容更符合个性化需求。
帮助开发者统一编码规范,提升团队协作效率。
避免从零开始编写代码,专注核心业务开发。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

AI 提示词价格
¥20.00元
先用后买,用好了再付款,超安全!

您购买后可以获得什么

获得完整提示词模板
- 共 130 tokens
- 6 个可调节参数
{ 项目类型 } { 技术框架 } { 用途描述 } { 必要元素 } { 代码风格规范 } { 包管理工具 }
获得社区贡献内容的使用权
- 精选社区优质案例,助您快速上手提示词
限时免费

不要错过!

免费获取高级提示词-优惠即将到期

17
:
23
小时
:
59
分钟
:
59