这半个月我几乎没怎么手写业务代码,全程跟 AI 结对编程,硬是从空文件夹搓出了一套「能下单、能聊天、能 AI 自动接客」的全栈电商系统。这篇文章把我踩过的坑、做过的技术选型、以及「怎么指挥 AI 一步步把活干完」都摊开聊聊。
先把丑话说前面:这不是一篇「Hello World 教程」,而是一篇实战复盘。我会重点讲三件事:
- 这个项目到底用到了 NestJS 的哪些语法和特性(够你快速上手);
- 系统长什么样、有哪些功能、架构怎么分层;
- 我是怎么用 AI Coding 把它一步步写出来的(这部分才是重点)。
包管理器全程 pnpm。后端 NestJS + Prisma + Zod,前端两套 UmiJS(B 端 antd v6、C 端 antd-mobile),AI 那一层用的是 Claude Agent SDK + MCP。
前言
如果对你有帮助,还请各位爷们、姐们走个面,点个赞😂。
本项目总共耗时4天(系分+开发+服务端部署),工具claude code cli + 豆包方舟api(ps:豆包性价比高😄)
需要账号密码体验的,评论区或者私信联系我:
1. NestJS 快速上手:本项目真正用到的那些 API
NestJS 的官方文档很全,但全 ≠ 好上手。这一节我只讲这个项目里真正落地的特性,看完你就能读懂后面的代码。
为什么后端选 Node + NestJS,而不是 Java/Go?
最关键的一条理由:这是个 AI 应用,而 AI 生态的「轮子」集中在 JS 和 Python 两个语言里 。LangChain、LlamaIndex、向量数据库 SDK、各家大模型 SDK......几乎都首发或只发 Python / TypeScript 版本 。本项目的核心------Claude Agent SDK(
@anthropic-ai/claude-agent-sdk)和 MCP(Model Context Protocol)都有官方的 TypeScript 包,开箱即用、跟着官方更新走,不用等第三方移植、也不用自己撸 HTTP 封装去对接。在「Python」和「TypeScript」之间选了后者,是因为前后端可以共用一套语言和类型 :Zod schema、卡片协议的
interface、错误码枚举都能在 server/h5/fe 之间复用心智,全栈一把梭。而在 Node 的一众框架里选 NestJS,则是图它像 Spring 一样工程化 ------模块化、依赖注入、装饰器、Guard/Pipe/Interceptor 这套分层,能把一个「电商 + AI」的中等复杂项目管得井井有条。一句话:AI 生态决定了语言,工程化诉求决定了框架。
1.1 模块化:一切皆 Module
NestJS 的世界观是「万物皆模块」。一个业务域 = 一个 Module,里面挂 Controller(管路由)和 Provider(管逻辑,通常是 Service)。
ts
@Module({
imports: [PrismaModule],
controllers: [OrderController, AdminOrderController], // C 端 + B 端两个控制器
providers: [OrderService],
exports: [OrderService], // 导出给别的模块用
})
export class OrderModule {}
类比着理解 :如果你写过 Express,会发现它是「一堆 app.use() / router.get() 平铺」,路由和逻辑全靠你手动 require 拼起来,项目一大就是一团乱麻。Nest 的 Module 更像 Angular 的 NgModule ,或者你可以把它想成「带了边界声明的 npm 包」:
imports= 「我依赖哪些别的模块」(类似package.json的dependencies);exports= 「我对外公开哪些能力」(类似 ES Module 的export,没 export 的 Provider 出了这个模块就用不了);providers= 「模块内部自用的实现」(类似模块内的私有函数)。
这套「显式声明依赖与可见性」的设计,让大型项目的边界非常清晰。我这个项目就是按业务域拆的:user / product / cart / order / wallet / address / chat / ai / mcp / notification ... 每个都是一个独立 Module,互不干扰。
1.2 依赖注入(DI):构造函数里要啥给啥
ts
@Injectable()
export class OrderService {
constructor(private readonly prisma: PrismaService) {} // 直接注入,框架帮你 new
}
类比着理解 :没有 DI 的世界长这样------const prisma = new PrismaService(),每个文件各 new 一个,连接池开一堆、还很难替换实现。DI 的本质是「控制反转 」:你不主动 new 依赖,而是「声明我需要它」,由框架这个容器统一创建并塞进来。
- 前端同学最熟的对应物是 React 的
useContext/ Context Provider:组件不自己造数据,而是从上层 Provider「拿」------Nest 的 DI 就是服务端版的、更彻底的 Context。 - 更接近的是 Angular 的 DI 或社区库 InversifyJS / tsyringe ,靠 TypeScript 的
emitDecoratorMetadata在编译期记下「构造函数第一个参数是PrismaService类型」,运行时按这个类型去容器里找实例注入。 - 默认是单例 (整个应用就一个
PrismaService实例,类似一个全局 singleton),所以数据库连接池只开一份。
好处是解耦 + 易测试 :单测时可以注入一个 mock 的 PrismaService,不用真连库。但 DI 也是循环依赖的温床------A 注入 B、B 又注入 A,容器就懵了。这个坑我后面 3.x 单独讲,血泪教训。
1.3 全局前缀 + 统一响应:让前端少写判断
main.ts 里干了几件「一劳永逸」的事:
ts
app.setGlobalPrefix('api'); // 所有路由统一 /api 前缀
app.useGlobalInterceptors(new TransformInterceptor()); // 成功响应统一包裹
app.useGlobalFilters(new BizExceptionFilter()); // 异常统一格式化
类比着理解 :setGlobalPrefix('api') 等价于 Express 里 app.use('/api', router) 把所有路由挂到 /api 下,只是 Nest 一行搞定全局。而「统一响应」这件事,前端同学一定不陌生------它就是 axios 响应拦截器的服务端镜像 :你在前端写 axios.interceptors.response.use(res => res.data),我在后端写一个拦截器把所有返回值统一塞进固定信封。
我定了一条铁律:所有业务接口都返回 HTTP 200,靠 body 里的 code 区分成功失败。
ts
// 统一响应结构
{ code: 0, message: 'ok', data: { ... } } // 成功
{ code: 1003, message: '无权限', data: null } // 业务失败,但 HTTP 仍是 200
这么做的好处是:前端拦截器只需要判断 code,不用到处 try/catch HTTP 状态码(不用区分 401/403/500 各种分支)。code 全部收敛到一个公共枚举 BizCode + CodeMsgMap,改文案改一处。
1.4 守卫(Guard):鉴权 + 权限两道闸
全局挂两个 Guard,顺序是先验身份再验权限:
ts
// app.module.ts
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 1. 验 token / api-key
{ provide: APP_GUARD, useClass: RolesGuard }, // 2. 验角色
]
类比着理解 :Guard 干的活,就是 Express 里的 passport.js 鉴权中间件 ,或者前端路由守卫 ------Vue Router 的 router.beforeEach、React Router 里包一层 <RequireAuth>。逻辑都一样:在进入「真正的处理逻辑」之前拦一道,返回 true 放行、false 拦截 。区别是 Nest 用装饰器声明式地配置,而不是手写 if (!req.user) return res.status(401)。
配套三个自定义装饰器,用起来非常顺手:
ts
@Public() // 跳过鉴权(登录接口用)
@Roles(ROLE_ROOT) // 只有 root 能调
@CurrentUser() user // 把当前登录用户注入进来
这里的关键技巧是 SetMetadata 给路由「贴标签」 ,Guard 运行时用 Reflector 把标签读出来再做判断------这其实就是一套轻量的「装饰器 + 反射元数据 」机制,和你用过的 reflect-metadata 是同一套底层:
ts
// decorators.ts ------ 一眼看懂
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const Roles = (...roles: number[]) => SetMetadata(ROLES_KEY, roles);
export const CurrentUser = createParamDecorator(
(_data, ctx) => ctx.switchToHttp().getRequest().user, // 从 req 上取,类似 req.user
);
💡 权限是包含关系 :比如某操作要求
role <= 1(root=0、admin=1 都能用),判断时绝不能写===。这是防越权的一个小细节,AI 第一次写出来用的是===,被我拦下来改成了<=。
1.5 管道(Pipe)+ Zod:参数校验我不用 class-validator
类比着理解 :Pipe 就是「请求进入 controller 之前的一道加工/校验工序 」,对标 Express 里的校验中间件(express-validator / joi)。前端同学更熟的是 React Hook Form 配 zodResolver------表单提交前用 Zod schema 校验。我在后端干的是一模一样的事,只是把 Zod 从前端搬到了接口入参上。
NestJS 默认搭配 class-validator(要写一个 class + 一堆 @IsString() 装饰器),但我个人更喜欢 Zod------一份 schema 同时是运行时校验 + 静态类型 ,不用再单独写一份 interface:
ts
// 只写 Zod,类型用 z.infer 推出来
export const CreateUserSchema = z.object({
username: z.string().min(1, '用户名不能为空'),
password: z.string().min(6, '密码至少6位'),
role: z.number().int(),
});
export type CreateUser = z.infer<typeof CreateUserSchema>; // 类型自动跟着 schema 走
然后写一个自定义 ZodValidationPipe 挂在 @Body() 上,校验不过直接抛参数错误:
ts
@Post('create')
@Roles(ROLE_ROOT)
create(@Body(new ZodValidationPipe(CreateUserSchema)) dto: CreateUser) {
return this.userService.create(dto); // 进到这里时 dto 一定是合法的
}
好处是单一数据源(single source of truth):schema 改了,类型自动跟着变,不会出现「校验规则和 TS 类型对不上」的经典 bug。
⚠️ TS 小坑 :
isolatedModules+emitDecoratorMetadata下,控制器参数的 DTO 类型必须用import type引入,否则编译报错。
1.6 拦截器 + 异常过滤器:成功和失败都统一格式
类比着理解:
Interceptor(拦截器)= AOP 切面 ,能在「方法执行前后」插逻辑。它基于 RxJS,handler.handle().pipe(map(...))这个写法,和前端用 RxJS 的pipe(map())完全一样------把 controller 的返回值流过一个map加工一遍。最贴近的前端类比还是 axios 拦截器:统一改造每一个出去/回来的数据。ExceptionFilter(异常过滤器)= Express 的错误处理中间件app.use((err, req, res, next) => {...})的 Nest 版,专门兜住抛出来的异常并格式化。
两者配合:
ts
// TransformInterceptor:把 service 的返回值包成 { code: 0, message, data }
// BizExceptionFilter:捕获自定义的 BizException,统一返回 HTTP 200 + 业务错误码
这样 service 层抛异常超级干净------只管 throw,不管格式化:
ts
if (!user) throw new BizException(BizCode.USER_NOT_FOUND);
剩下的格式化全交给过滤器,全局一致。这就是「关注点分离」:业务代码专注业务,横切关注点(日志、包装、错误处理)抽到拦截器/过滤器里。
1.7 WebSocket Gateway:智能客服的实时通道
智能客服要做「打字机式」流式回复,HTTP 轮询太挫,直接上 socket.io。
类比着理解 :如果你直接用过 socket.io 的 io.on('connection', socket => socket.on('xxx', ...)),那 Gateway 就是它的「装饰器版」------把 socket.on('customer:consult', handler) 写成 @SubscribeMessage('customer:consult'),本质就是 Node EventEmitter 的 on/emit 换了层声明式皮肤 。NestJS 提供了 @WebSocketGateway:
ts
@WebSocketGateway({ cors: true })
export class ChatGateway {
@SubscribeMessage('customer:consult') // 等价 socket.on('customer:consult')
async onConsult(client: Socket, payload: { scene: number }) {
// 顾客发起咨询 -> 进入 AI 服务
}
}
socket 事件设计成一组语义化通道:customer:consult(发起咨询)、conversation:aiServing(AI 接管)、message:stream(流式片段)、message:streamEnd(流式结束)、customer:transferHuman(转人工)。一收一发都是 emit 一个具名事件,和前端 socket.emit('message:send', data) 对得上。
1.8 SSE:B 端智能体的流式通道(裸写 Express 响应)
C 端实时客服用 WebSocket(双向、要转人工),但 B 端「后端小智」是纯机器对话 ------它只跟 AI 聊,没有真人客服介入、不需要服务端主动推送,永远是「运营问一句、AI 流式吐一段」的单向数据流。这种场景上 WebSocket 是杀鸡用牛刀,用 SSE(Server-Sent Events) 这种「服务端单向推流」更轻、更贴合。
NestJS 虽然有 @Sse() 装饰器,但它返回的是 Observable、且会被我的全局响应拦截器(统一包 {code,message,data})污染。所以这里我直接拿到 Express 的 Response 裸写流,绕开拦截器:
ts
@Post('stream') // 用 POST:要带 Authorization 头 + body
async stream(
@CurrentUser() user: IAuthUser,
@Body(new ZodValidationPipe(AiChatStreamSchema)) dto: AiChatStreamDto,
@Res() res: Response, // 注入原生 Express 响应
) {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 关键:关掉 nginx 缓冲,否则不流式
res.flushHeaders?.();
// 每张卡片写成一个 SSE 事件:data: <json>\n\n
const write = (card: IChatCardData) => {
res.write(`data: ${JSON.stringify(card)}\n\n`);
};
await this.chat.streamReply(user, dto, write); // 流式期间不断 write
res.end();
}
几个踩坑要点:
- SSE 帧格式固定是
data: <内容>\n\n(两个换行表示一帧结束),前端就靠这个分隔符切包。 X-Accel-Buffering: no必加------生产环境走 nginx 时,不关缓冲会把整条流憋到最后一次性吐出来,「打字机效果」直接没了。streamReply内部永不抛异常 ,出错也是write一张state:'error'的卡片,保证响应能res.end()干净收尾。
B 端前端怎么消费这条 SSE? 不用浏览器原生 EventSource------因为它只能 GET、没法带 Authorization 头。改用 fetch(项目里等价于 @microsoft/fetch-event-source)拿 ReadableStream 自己解析:
ts
const res = await fetch('/api/b/ai/chat/stream', {
method: 'POST',
headers: { 'content-type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(params),
signal, // 传 AbortSignal,点「停止」时 abort 掉
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // 末尾可能是半个事件,留到下一轮
for (const evt of events) {
const line = evt.split('\n').find((l) => l.startsWith('data:'));
if (line) handlers.onCard(JSON.parse(line.slice(5).trim())); // -> IChatCardData
}
}
要点:按 \n\n 切事件、把末尾不完整的那段缓存回 buffer ,避免半个 JSON 解析报错;AbortSignal 让「停止生成 / 发新消息」能直接掐断流。前端拿到的每个 IChatCardData 就交给 2.4 讲的卡片渲染器。
1.9 OnModuleInit:服务启动时干点事
有些初始化逻辑得在服务起来时跑,比如「把数据库里的智能体配置加载进内存 Map」。实现 OnModuleInit 接口即可:
ts
@Injectable()
export class AgentRegistry implements OnModuleInit {
async onModuleInit() {
const agents = await this.prisma.aiAgent.findMany({ where: { platformType: PLATFORM_C } });
// 按 scene 建索引,对话时秒查
}
}
1.10 Swagger:接口文档自动生成
@nestjs/swagger 一挂,/swagger 就有可视化文档了。我定了个规矩:每加一个接口,必须去 /swagger 确认它出现了,相当于一次冒烟测试。
ts
const config = new DocumentBuilder()
.setTitle('AI E-Buy API')
.addBearerAuth()
.build();
SwaggerModule.setup('swagger', app, document);
到这里,NestJS 的核心招式你已经会了七八成,下面看系统本身。
2. 项目长啥样:功能 + 架构全景
2.1 它能干啥(功能脑图)
整个系统分 C 端(顾客) 和 B 端(后台运营) 两大块,核心亮点是那套 AI 智能客服。先来张功能脑图:
2.2 用户体系:5 种角色,垂直 + 水平双重防越权
| 角色 | role 值 | 能干啥 |
|---|---|---|
| 超级管理员 root | 0 | 唯一能增删改用户,全部权限 |
| 管理员 admin | 1 | 商品/订单/售后/智能中心 |
| 员工 staff | 2 | 商品/订单/售后(不能动用户和智能配置) |
| 客服 service | 4 | 只管咨询工作台 |
| 顾客 customer | 6 | 只能走 /c/ 接口 |
接口按前缀拆开:B 端 /b/、C 端 /c/ ,同一个 ProductService 给两端复用,但 C 端的查询不返回进货价这种敏感字段。
2.3 智能客服全流程(这是灵魂)
顾客在商城点「在线客服」,背后发生了什么?看时序图:
几个关键设计点(都是踩坑换来的):
- AI 禁止碰数据库 。Claude 不能写 SQL、不能跑 Shell,所有数据只能通过 MCP 工具拿。这是硬性安全约束。
- MCP 工具本质是 HTTP 客户端 ,它不直接调 service,而是
fetch本地 REST API。这么做是为了彻底干掉循环依赖(后面 3.x 详述)。 - 数据隔离 :C 端对话时把顾客
userId通过x-acting-user-id头透传,MCP 只能查这个顾客自己的订单,杜绝水平越权。 - 转人工:AI 回复带「卡片协议」,识别到顾客想找人工 / 抱怨时,直接推一个「联系人工客服」按钮,点一下就转真人。
2.4 卡片协议:前端只认一种「会说话」的数据格式
整个项目里,对话回复从来不是一段裸文本------它是一个结构化的「卡片」。前端拿到卡片只管渲染,不用关心后面是 AI 还是真人、是文本还是表格。C 端和 B 端各有一套卡片协议,思路一致、形态不同。
① C 端(顾客商城 / WebSocket):markdown + 可点按钮
C 端走 socket.io,回复会被封装成一个卡片 JSON 持久化、下推:
ts
interface ICardAction { text: string; cb: string; } // 按钮:文案 + 回调名
interface ICard {
type: string; // 'md' = markdown 正文
content: string; // markdown 内容
actions?: ICardAction[]; // 可选的交互按钮
}
这套卡片协议是「机器对话」和「人工对话」共用的 ------不管顾客这会儿是在跟 AI 聊,还是已经转给了真人客服,前端收到的都是同一种 ICard,渲染逻辑完全一致。区别只在于「谁来生产这张卡片」:AI 服务生产的卡片可能带「联系人工客服」按钮,真人客服回复时同样可以挂载操作按钮(如推送商品卡、发起售后)。前端做纯粹的「卡片渲染器」,无需为「AI / 真人」分叉两套 UI。
关键在 actions:每个按钮是 { text, cb },cb 映射到前端一个具名处理器。比如「转人工」就是 { text: '联系人工客服', cb: 'chatHuman' },前端按 cb 找到 chatHuman 处理器,点一下就转真人。按钮不是写死的 ------buildAiCardContent 只在顾客消息里出现「人工/真人/在线客服...」、或吐槽 AI「难用/答非所问...」、或命中兜底回复时才追加这个按钮。所以一句「你好」绝不会冒出转人工按钮,避免打扰。
流式三段式:message:new(type=7 的 AI 占位消息)→ message:stream(打字机累加 content)→ message:streamEnd(最终完整卡片 JSON)。
还有一层「拓展信息(attachments)」:顾客可以把商品/订单/售后单卡片作为附件 带进消息,后端
cardToReadable把这些卡片快照转成可读文本喂给 AI------这样顾客问「我这个订单啥时候发货」时,AI 直接从上下文读到订单号,不用再反问。
② B 端智能体(运营后台 / SSE):state 状态机 + md/table
B 端「后端小智」走 SSE (前端用 @microsoft/fetch-event-source),卡片结构带了一个 state 状态机 ,驱动前端渲染。这套协议来自项目里的 fe-chat-schema.md 设计文档,数据结构如下:
ts
interface IData {
state: 'loading' | 'end' | 'error'; // 状态机:思考中 / 完成 / 出错
type: string; // 'md' markdown | 'table' antd 表格
content: string; // 渲染内容(md 正文 / 流式文本)
action: string[]; // 如 ['feed'] 点赞/踩 反馈按钮
errMsg: string; // state=error 时渲染这条
}
三种典型卡片(对应 fe-chat-schema.md 的示例):
ts
// 示例 1:思考中 ------ 大模型 / SSE 还没返回,前端渲染 loading 卡片
{ state: 'loading', type: 'md', content: '', action: ['feed'], errMsg: '' }
// 示例 2:出错 ------ state=error 时只渲染 errMsg 即可,
// 不用管 type / content,整张卡片渲染成「错误卡片」
{ state: 'error', type: 'md', content: '', action: ['feed'], errMsg: '处理出错了' }
// 示例 3:完成 + 表格 ------ type='table' 时把 data.props 原样作为
// antd <Table> 的 props 传入,结构化数据用表格而非 markdown 展示
{ state: 'end', type: 'table', data: { props: {} }, content: '', action: ['feed'], errMsg: '' }
渲染规则:
state === 'loading'→ 渲染 loading 卡片(「思考中...」),其余字段忽略;state === 'error'→ 只看errMsg,渲染成错误卡片,type/content不用管;state === 'end'→ 按type渲染:'md'用 markdown,'table'把data.props透传给 antdTable;action: ['feed']→ 在卡片下方挂「点赞 / 踩」反馈按钮。
完整对话流程(同样出自设计文档):
- 点「智能助手」按钮弹窗,只能选 B 端的智能体 ;可随时切换智能体,切换时输入框、对话列表、对话详情都要绑定到正确的
agentId。 - 左侧是对话列表 + 新建对话。新建对话传
agentId调接口生成sessionId;历史对话复用已有sessionId。 - 前端先调接口生成
chatId,再带着query + chatId请求 SSE 接口。停止生成有两种触发:用户点「停止」按钮,或直接再发一条新消息(都会调停止接口)。 chatId是「这一轮对话」的身份证 :不属于当前chatId的 SSE 数据一律丢弃------这样「停止上一轮 + 立刻发新一轮」时,旧流的残余帧不会污染新回复。
两端协议异曲同工:把「渲染什么 + 能交互什么」编码进一个 JSON,前端做纯粹的「卡片渲染器」。AI 想加个按钮、换个表格,改的是数据,不是前端代码。
2.5 总体架构
注意那条自调用回环 :McpService 通过 HTTP 打回自己的 /api。看着绕,但它换来了「AI 工具层和业务 service 层零耦合」,循环依赖问题从根上消失。
🌟 这个项目的技术亮点(划重点)
如果只记住几件事,记这几个就够了:
- AI「零数据库权限」的硬隔离 :Claude 既不能写 SQL 也不能跑 Shell(
tools:[]清空内置工具 +canUseTool白名单只放行mcp__ebuy__*),取数的唯一通道是 MCP 工具。安全边界落在「能力层」,不是靠提示词嘴上说说。- MCP 工具 = HTTP 客户端,自调用打回
/api:工具不直接调 service,而是fetch自己的 REST 接口。一招根除循环依赖,还让「AI 走的路」和「人走的路」是同一套接口、同一套鉴权。x-acting-user-id透传实现数据隔离 :C 端对话把顾客userId透传给 MCP,AI 只能查到这个顾客本人的订单,水平越权从源头堵死;B 端则按当前登录运营的真实角色鉴权。- 统一卡片协议 :C 端「机器/人工」对话共用一套
ICard,B 端用state状态机卡片(md/table),前端只做「卡片渲染器」,AI 想加按钮/换表格改的是数据不是代码。- 智能体可配置 + 知识库可投喂 + 提示词可审计 :SOP 走后台配置不硬编码、知识库通过
knowledge_query工具按需检索、再用 Claude 扮红队对提示词做安全审计。AI 能力被「驯化」成一个可运营、可治理的模块。- 复用本机 CLI 登录态接入 Agent SDK :不另配 API Key,读
~/.claude/settings.json的 env 复用本地 Claude 登录态,本地开发零成本起跑。
2.6 server 端
技术栈:NestJS 11 + Prisma 7 + Zod 4 + socket.io + Claude Agent SDK。
- 19 张表(Prisma model):User / Wallet / Product / CartItem / Address / Order / OrderItem / AfterSale / Conversation / ChatSession / Message / AiAgent / KnowledgeBase / ApiKey / Notification / ActivityNotice ...
- 数据库迁移 全部走
prisma migrate,生产环境用prisma migrate deploy同步。 - 公共层
common/:code.ts(错误码枚举)、decorators.ts、*.guard.ts、*.interceptor.ts、*.pipe.ts、constants.ts(所有魔法值都收敛在这)。 - AI 层
ai/+mcp/:Agent SDK 接入、MCP 工具定义、智能体注册表、提示词安全检测。
2.7 h5 端(C 端顾客商城)
技术栈:UmiJS4 + antd-mobile v5 + mobx + socket.io-client,端口 3000。
- 约定式全局布局
layouts/index.tsx+ 底部 TabBar(首页/消息/购物车/我的)。 - 状态管理 + 持久化全靠 mobx :
stores/下按域拆userStore / homeStore / mineStore / chatStore / notificationStore,全部makeAutoObservable。其中userStore负责登录态持久化 ------构造时从localStorage(key=USER_INFO_KEY)读回{token,id,username,role},setLogin写入、logout清除,并顺手断开 socket、reset掉聊天/通知 store 的未读状态。刷新页面登录态不丢,就是靠它「内存 observable ↔ localStorage」双向同步。 - 三个 Tab 页的数据同样用 mobx store 托管,App 启动预加载一次,之后只在「下拉刷新」时重新请求,体验丝滑(
async改 observable 记得用runInAction包裹)。 - 首页瀑布流双列,用
new Image()测真实宽高比按最短列分配。 - 智能客服页
pages/service用react-markdown渲染 AI 气泡,socket 流式更新。
2.8 fe 端(B 端运营后台)
技术栈:UmiJS4 + antd v6 + mobx + @uiw/react-md-editor,端口 8000。
- 路由配置式:
/login无布局,其余走BasicLayout(鉴权 + 角色菜单)。 - 菜单按角色动态生成,root 能看全部,staff 只看商品/订单/售后。
- 智能中心里有个**「智能检测」**功能很有意思:用 Claude 扮演红队,对智能体的提示词做安全审计,输出风险等级 + 模拟攻防记录 + 一键改良(带 diff 确认弹窗)。
- 还有个 B 端智能助手「后端小智」,运营人员可以直接用自然语言查订单、发货、审售后------它走 SSE 流式 + MCP 工具,并且严格按当前登录用户的真实角色鉴权。
⚠️ antd v6 的 break change :
Modal用destroyOnHidden(不是destroyOnClose)、mask={{ closable: false }}(不是maskClosable)。AI 经常写成 v5 的旧 API,得盯着改。
2.9 智能中心:让 AI「可配置、可投喂、可审计」
整个 AI 能力不是写死在代码里的,而是后台可运营的。这块由三个功能撑起来,是这个项目我最得意的部分:
① 智能体配置(AiAgent)------ AI 的「人设」由后台说了算
每个智能体有 name / description / sopContent(提示词)/ scene(场景)/ platformType(C端/B端)。这里有个我反复强调的硬性约束:
AI 的人设和话术必须来自后台配置的
sopContent,绝不能硬编码在代码里。
为什么这么较真?因为运营要能随时调话术、改场景,而不用找开发改代码发版。后台改完 agent,注册表 reload() 即时生效。场景分通用 / 商品 / 订单 / 售后,对话时按场景精确匹配到对应智能体。
② 知识库(KnowledgeBase)------ 给 AI 投喂业务政策
退换货政策、运费规则、活动说明这些「企业私有知识」,AI 不可能凭空知道,得靠知识库投喂。每条知识有 title / content / platform(C端/B端/全部)。
关键设计:AI 读知识库也必须走 MCP 工具 knowledge_query,而不是后端预先把知识塞进提示词 。平台筛选语义是「本端 + 全部」------C 端对话查 [C端, 全部],B 端查 [B端, 全部],互不串台。
③ 智能检测 ------ 用 AI 审计 AI 的提示词安全
这个功能很「赛博朋克」:点一下「智能检测」,后端让 Claude 扮演红队,对智能体配置发起 6 类攻击(正常咨询、无关话题、提示词注入、越权、泄密、危险操作),然后输出:
- 总评 + 风险等级(低/中/高);
- 模拟攻防问答记录(每条标「守住/勉强/失守」);
- 有问题的提示词片段(前端用红色波浪下划线标出)+ 修改建议;
- 一键改良:弹窗展示 diff → 确认后直接把改良配置写回该智能体。
一句话:智能体配置定义 AI 是谁,知识库决定 AI 知道什么,智能检测保证 AI 不被人忽悠。 三者合一,AI 才算「可运营」。
3. 重头戏:怎么用 AI Coding 一步步把它写出来
这才是这篇文章我最想分享的。说实话,AI 不是「你说一句它就帮你写完整个项目」,它更像一个执行力超强但需要你把关方向的实习生。下面是我总结的一套「指挥 AI」的方法论。
3.1 第一步:先写「规则文件」,而不是先写代码
我做的第一件事不是让 AI 写登录接口,而是先和它一起把 CLAUDE.md(项目规则文件) 定下来。比如:
md
## 规范
- 校验用 zod,不写 class-validator;只写 Zod schema,类型用 z.infer
- 所有业务接口用 POST,HTTP 状态码都是 200,靠 data.code 区分
- 错误码用公共 Map 透出
- 权限判断用 <= 不用 ===(包含关系)
- 列表接口统一分页
- 数据库操作必须 try/catch,对外只返回"操作异常",不暴露 SQL 错误
- B/C 端接口分别加 /b/ 和 /c/ 前缀
为什么这一步最重要? 因为 AI 每次写代码都会读这个文件。规则定得越细,它跑偏的概率越低,你后面 review 的成本越低。我甚至把「测试账号」「端口号」「不要重启服务(热更新)」这些都写进去了。
3.2 第二步:按业务域「一个一个」推进,不要贪多
我的推进顺序大致是:
每个模块都遵循同一个套路,我会给 AI 一个结构化的指令,比如:
「实现购物车模块。Prisma 加 CartItem 表(userId + productId 复合唯一),后端
c/cart控制器,全 POST:add(已存在则数量累加)、list、updateQuantity、remove。注意水平越权:改/删要先校验归属。然后 h5 接上真实接口,重写 pages/cart。」
指令里包含了:数据模型、接口清单、权限约束、前端落点。AI 拿到这种指令,一次就能产出八九不离十的代码。
3.3 第三步:让 AI 自己验证(curl / tsc),而不是你手点
我几乎不手动测接口。每写完一个模块,我会让 AI:
npx tsc --noEmit做类型检查;- 用
curl跑一遍核心链路(登录拿 token → 调接口 → 验返回 code); - 去
/swagger确认接口注册成功。
比如售后审批流改完,AI 自己跑了一长串验证:「申请(无理由→1001 / 带理由→PENDING)→ 重复申请→1001 → 越权→1014 → reject→3 钱包库存不变 → 重申→1 → approve→2 钱包+金额+库存全对 → 再 approve→1001」。这种回归测试它跑得比我勤快多了。
3.4 那些 AI 自己踩、自己填的坑(精选)
坑 1:循环依赖 OrderService → NotificationService → ChatGateway → AiChatService → McpService →(回)OrderService,一个完美的环。forwardRef 救不了文件级 require 环。最后的解法很优雅:把 McpService 改成纯 HTTP 客户端,不再注入任何 service,环直接没了。
教训:遇到 provider 环,优先考虑「调用方改 HTTP / 解耦」,而不是硬上
ModuleRefhack。
坑 2:AI 居然真的能跑 Shell 我让 Claude「用 bash 跑个 whoami」试探,它真的执行了 并返回了我的用户名 😱。光配 allowedTools + canUseTool 拦不住内置工具。最终解法是给 SDK 传 tools: [],彻底移除全部内置工具,只留 MCP 工具。安全这块必须纵深防御。
坑 3:base64 图片撑爆上下文 顾客问「查我的订单」,AI 却回「数据量过大啦」然后开始瞎编订单号。排查半天发现:订单里的商品图是 base64(单张 ~190KB),order_list 返几单就是几百 KB,直接把模型上下文撑爆。解法:MCP 返回前递归剥离所有 image 字段,反正 AI 回复纯文本根本用不到图。
坑 4:状态码 AI 看不懂 MCP 返回的订单状态是数字(1/2/3/4),模型会直接把「1」念给顾客。得在系统提示词里补一段「状态码含义对照表」,要求它翻译成「待发货/已发货」这种人话。
坑 5:切 Tab 每次都重新加载,列表「闪一下」 C 端首页、我的、消息这几个 Tab,最初每次切过去都重新请接口------切来切去白屏闪烁、还浪费请求。我让 AI 把这几个页面的数据统一收进 mobx store :用 makeAutoObservable 建 homeStore / mineStore / chatStore 等,App 启动时 init() 预加载一次,之后页面切换直接读 store 里的 observable,不再重新请求 ,只有用户「下拉刷新」才 refresh() 重拉。登录态则交给 userStore------构造时从 localStorage 读回、setLogin/logout 双向同步,刷新页面也不掉登录。
这一步带来两个收益:切 Tab 秒开(数据已在内存)+ 登录态持久化(刷新不丢) 。要注意
makeAutoObservable只会把方法同步段包成 action,async里await之后改 observable 必须用runInAction包一层,否则 mobx 会告警。
3.5 第四步:把「踩坑经验」沉淀成记忆
这是我觉得最爽的一点------AI 会把每次解决的坑写进记忆文件。下次遇到类似问题,它直接调取经验,不用重新踩。比如「Prisma 7 生成的 client 是 .ts 不能直接 node require,验证走 curl 最稳」这种细节,沉淀下来后效率直接起飞。
3.6 一个真实片段:连「安全审查」都让 AI 自己做
写到提交代码前,我让 AI 扫一遍有没有硬编码的账号密码。它自己 grep 出 seed.ts 里的 zlj@123、deploy.sh 里的 123456 默认密码、文档里的测试账号,然后逐个清理成环境变量 / 占位符。整个过程我只说了一句「检测 git add 是否有敏感文件」。
3.7 真实对话切片:AI 结对到底长啥样
为了让你直观感受「跟 AI 结对」的节奏,我把这个项目收尾阶段我俩真实的对话摘几条出来------你会发现,大部分时间我都在用大白话提需求,AI 负责把活干漂亮:
① 几个字的 UI 微调
我:「发送者的头像应该在最右方的」
AI 立刻定位到聊天组件,发现用户气泡用了 flex-direction: row-reverse 导致头像翻到了气泡左边,改成 row + justify-content: flex-end 修好。紧接着:
我:「发送气泡改为蓝底白字」
它发现气泡背景本来就是蓝的,但里面是 Markdown 渲染、文字仍是深色,于是给 .wmde-markdown 补了 color: #fff。这种几个字的需求,AI 接得又快又准。
② 强化 AI 的「人设」
我:「注意后管小智的工作职责,让用户更便捷地完成数据查询。不能执行 SQL」
AI 同时改了两处提示词:给系统提示词加「## 你的工作职责(核心定位)」段(主动理解意图、优先按 ID 精确取数、归纳解读结果),并把「禁止 SQL」升级成「绝对红线」------没有任何数据库访问能力,唯一取数途径是 MCP 工具。
③ 部署后清理
我:「种子数据可以删掉了,因为已经部署了」 我:「迁移文件里面的账号密码也要删掉」 我:「迁移文件里面不是有 root 的账号密码吗」
AI 删了 seed 文件后,仔细把所有迁移文件读了一遍,如实告诉我「迁移里没有 root 账号密码,password 只是建表的字段定义,root 密码原本只在已删除的 seed.ts 里」------没有为了迎合我而瞎改,而是基于事实澄清。这种「不顺着你瞎说」的特质,反而让我更信任它。
这些切片想说明一件事:AI 结对不是「写一个长 prompt 然后等它交付」,而是一来一回的高频对话。需求可以很碎(改个颜色、挪个头像),也可以很重(强化安全约束、排查 git 事故),它都能接住。你要做的,是把方向把住、把事实核对清楚。
3.8 用提示词把 AI 拉回正轨:那些「偏离本意」的瞬间
AI 很能干,但它不总能猜中你的本意 ------尤其是涉及架构取舍、安全边界这种「魔鬼藏在细节里」的地方。它会按一个看似合理、其实跑偏的方向一路写下去。这时候,及时用一句提示词把它拽回来,比事后返工省太多。下面几个是这个项目里真实发生过的「纠偏」:
① 知识库:别预加载,要让 AI「自己去查」
最初实现智能客服时,AI 图省事,直接在拼接系统提示词的时候 prisma.knowledgeBase.findMany() 把所有知识库内容一股脑塞进 system prompt。看着能用,但问题很大:知识库一多,prompt 爆炸、token 飙升,还每轮都带着全量。
我:知识库不要预加载进提示词,要让 AI 通过 MCP 的
knowledge_query工具按需检索。
纠偏后,知识库变成了一个「可被工具查询的数据源」------AI 判断需要时才调用 knowledge_query,按平台过滤(本平台 + 全平台)拿回最相关的几条。这是「把知识塞给模型」和「给模型一个查知识的能力」的本质区别,后者才可扩展。
② SOP:提示词必须来自后台配置,不许硬编码
AI 一开始把客服的话术、SOP 直接写死在代码常量里。方便是方便,但这等于剥夺了运营自助调整 AI 行为的能力。
我:智能体的
sopContent必须来自后台配置(数据库AiAgent表),不能硬编码。
于是 SOP 落到了 AiAgent.sopContent 字段,后台「智能体配置」页面可视化编辑,运营改完即时生效。但这里又埋了一个安全口子------既然 SOP 可被后台改,会不会有人在 SOP 里写「忽略所有限制」来越权?所以我又补了一刀:
我:安全与隐私红线要硬编码在系统提示词里、优先级最高,B 端改 SOP 不能覆盖它。
最终形成「底层红线代码焊死 + 上层话术后台可配」的分层结构------既灵活又安全。
③ 工具收口:光在提示词里说「不许」远远不够
我要求「后端小智」不能执行 SQL。AI 第一反应是在提示词里加一句「禁止执行 SQL」。但提示词是可以被注入绕过的,光靠嘴说不算数。
我:不能执行 SQL(这不只是提示词的事,要从工具层面真正锁死)。
真正的解法落在 Agent SDK 的配置上:tools: [] 移除全部内置工具(Shell/Read/Write 一个不留),canUseTool 只放行 mcp__ebuy__* 前缀的白名单工具,settingSources: [] 不加载任何本地 CLAUDE.md。提示词负责「态度」,工具配置负责「能力边界」------两者都做,才叫真的锁死。
④ 基于事实,而不是顺着我说
前面 3.7 提过的那次------我笃定「迁移文件里有 root 账号密码」,催它删。AI 读完所有迁移文件后没有顺着我改 ,而是如实指出迁移里只有 password 字段定义、真正的密码只在已删的 seed.ts 里。这其实是反向的「纠偏」:我偏离了事实,AI 把我拉了回来。
这一节想说的是:AI 的「跑偏」往往不是能力问题,而是它没拿到你脑子里那条没说出口的约束(可扩展性、可配置性、安全边界、事实依据)。你的提示词,本质就是在持续地、显式地把这些隐性约束补给它。把方向把稳,剩下的它干得比你快。
4. 总结:AI Coding 到底改变了什么
撸完这个项目,我最大的感受是:AI Coding 把程序员的工作重心,从「写代码」挪到了「定义问题 + 把关质量」。
几点心得,送给想试的同学:
- 规则先行 。先和 AI 把
CLAUDE.md这类规则文件定细,它跑偏的概率会断崖式下降。这比任何 prompt 技巧都管用。 - 小步快跑。一个业务域一个业务域推进,每步都让它自验(tsc + curl + swagger)。别指望一句话生成整个系统。
- 方向你来把,细节它来抠 。架构决策(比如「用 HTTP 解耦干掉循环依赖」「
tools:[]锁死内置工具」)需要你拍板;但具体实现、边界测试、回归验证,AI 比你勤快。 - 安全这根弦不能松。AI 默认会给自己开很大权限(能跑 Shell!),涉及 AI Agent 的项目,工具收口、数据隔离、提示词加固,一个都不能少。
- 让经验可复用。把踩过的坑沉淀成记忆 / 文档,AI 会越用越「懂你的项目」。
说到底,AI 不会取代你的判断力,但它能把你从重复劳动里解放出来。这个带智能客服的全栈电商系统,搁以前我一个人吭哧吭哧得写小半个月,现在我把更多时间花在了「想清楚要什么」上------而这,恰恰是最有价值的部分。
项目技术栈速览:NestJS 11 / Prisma 7 / Zod 4 / MySQL / socket.io / Claude Agent SDK + MCP / UmiJS4 / antd v6 / antd-mobile v5 / mobx。全程 pnpm,全程 AI 结对。
如果这篇对你有帮助,欢迎点赞收藏。有问题评论区见,我会尽量回 🙌