热门角色不仅是灵感来源,更是你的效率助手。通过精挑细选的角色提示词,你可以快速生成高质量内容、提升创作灵感,并找到最契合你需求的解决方案。让创作更轻松,让价值更直接!
我们根据不同用户需求,持续更新角色库,让你总能找到合适的灵感入口。
根据用户指定的项目类型、技术框架及用途生成完整项目脚手架代码,包含必要元素和基础结构,确保代码高质量、可复用,帮助开发者快速搭建项目模板,加速开发启动。
下面提供一套基于 React 18 + TypeScript + Vite 的前端管理后台脚手架样板代码,满足你提出的功能与工程规范需求。内容包含项目目录结构、关键代码示例、配置、测试、Mock、CI/CD、部署与常用脚本。包管理工具使用 pnpm,状态管理采用 Redux Toolkit 并提供可替换为 Zustand 的抽象接口,路由使用 React Router v6 并包含布局/懒加载/路由守卫,鉴权提供 JWT 存储与刷新、RBAC 权限中间件。API 层支持 REST/GraphQL 可切换,包含拦截器、重试与缓存。内置数据看板、订单看板、用户与角色、商品与库存 CRUD 表格、导入导出 CSV/Excel、全局消息与异常上报、多语言与暗黑模式以及响应式与无障碍支持。
一、快速开始
二、目录结构
三、关键文件与代码示例
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" } }
环境变量与 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'] } } } } }; });
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(
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
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 }) => (
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:
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));
export const store = configureStore({ reducer: { auth: authReducer, users: usersReducer, products: productsReducer, inventory: inventoryReducer, orders: ordersReducer }, middleware: getDefault => getDefault() });
export type RootState = ReturnType
// hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
export const useAppDispatch = () => useDispatch
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。
export interface RequestOptions extends RequestInit { retry?: number; cache?: boolean; }
const requestInterceptors: Interceptor
export const addRequestInterceptor = (fn: Interceptor
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;
};
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); } };
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...
};
};
| {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
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}
msg ${m.type}}>{m.text}}
}
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
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 (
}
导入导出工具: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); };
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()) } }))) ];
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(); });
.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'] };
FROM node:18-alpine AS build WORKDIR /app COPY . . RUN corepack enable && pnpm install --frozen-lockfile RUN pnpm build
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;"]
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');
四、可扩展说明
如需我将以上模板打包为可直接 degit 的仓库结构或根据你团队的具体 API 字段与权限模型进一步完善 CRUD 表单、表格筛选/分页、图表配置面板以及异常上报对接第三方服务(如 Sentry),请继续说明。
下面提供一套可直接使用的 NestJS + TypeScript BFF 网关样板工程,满足你的功能与结构要求。内容包含目录结构、核心代码文件、配置、数据库与缓存、队列、监控、测试、部署与 CI/CD。可直接使用 yarn 运行与构建。
目录结构
关键代码示例
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 {}
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()),
});
}
}
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;
}
}
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,
});
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 },
});
}
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;
}
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');
})();
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 缓存与失效策略。
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 手动缓存与失效。
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 } });
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;
}
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。
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
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
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
{
"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'],
});
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);
}
}
运行指南
可扩展建议
若需要我生成完整项目仓库的压缩包或根据你的域模型补全 products/orders 的 DTO/Controller/Service 代码,请告知。
以下为可直接初始化的多包脚手架,满足你提出的技术与功能要求。结构清晰、可扩展、离线优先,内置本地加密 SQLite/SQLCipher、全文检索、增量同步、自动更新、安全 IPC 与 CSP、崩溃恢复和日志采集,支持跨平台打包与差分更新。包管理工具为 npm,统一代码风格使用 Prettier。
目录结构
关键配置与脚本 根 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
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
数据层:加密 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
端到端加密与增量同步策略 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
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
packages/renderer/src/components/MarkdownPreview.tsx
import ReactMarkdown from 'react-markdown';
export function MarkdownPreview({ content = '' }: { content?: string }) {
return
packages/renderer/src/components/RepoTree.tsx export function RepoTree() { return
packages/renderer/src/components/GlobalSearch.tsx import { useState } from 'react'; export function GlobalSearch() { const [q, setQ] = useState(''); return (
快捷键与沉浸式写作 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
自动更新与签名(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'); }
开发/调试/发布脚本
渲染与预加载包 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
环境变量管理与应用设置持久化 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
测试样板 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(); });
发布渠道与差分更新适配
使用指南(摘要)
安全说明
后续完善建议
该样板代码已覆盖主进程、预加载、渲染与共享包,提供安全、数据、同步、UI、构建、脚本、测试、配置、发布与文档的基础结构。可直接用于团队的离线优先知识库桌面客户端项目起步。
为开发者提供高效的脚手架代码生成工具,快速搭建项目基础架构,简化开发流程,提升工程效率。
需要快速启动产品开发,使用提示词一键生成标准化项目结构和基础代码,为产品验证赢得更多时间。
通过提示词提供的统一模板生成代码,规范团队工作流程,保障团队开发输出的高一致性与高质量。
在承接多样化项目时,通过提示词高效生成各类语言与框架的样板代码,从而提高接单效率并专注于业务逻辑设计。
将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。
把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。
在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。
免费获取高级提示词-优惠即将到期