从 0 到 1:我用 AI Coding 撸了一套带「智能客服」的全栈电商系统

这半个月我几乎没怎么手写业务代码,全程跟 AI 结对编程,硬是从空文件夹搓出了一套「能下单、能聊天、能 AI 自动接客」的全栈电商系统。这篇文章把我踩过的坑、做过的技术选型、以及「怎么指挥 AI 一步步把活干完」都摊开聊聊。

先把丑话说前面:这不是一篇「Hello World 教程」,而是一篇实战复盘。我会重点讲三件事:

  1. 这个项目到底用到了 NestJS 的哪些语法和特性(够你快速上手);
  2. 系统长什么样、有哪些功能、架构怎么分层;
  3. 我是怎么用 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.jsondependencies);
  • 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.ioio.on('connection', socket => socket.on('xxx', ...)),那 Gateway 就是它的「装饰器版」------把 socket.on('customer:consult', handler) 写成 @SubscribeMessage('customer:consult'),本质就是 Node EventEmitteron/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 智能客服。先来张功能脑图:

mindmap root((AI 电商系统)) C端 顾客商城 首页 瀑布流 搜索 商品详情 加购 购物车 下单结算 钱包支付 订单 确认收货 售后 申请退款 收货地址 省市区级联 我的钱包 智能客服 ⭐ B端 运营后台 商品管理 订单管理 发货 取消 售后管理 审批 用户管理 仅root 钱包管理 活动通知 智能中心 智能体配置 知识库管理 智能检测 红队审计 B端智能助手 后端小智 ⭐ API Key 管理 AI 能力层 ⭐ Claude Agent SDK MCP 工具集 流式对话 SSE/WebSocket 转人工 卡片协议

2.2 用户体系:5 种角色,垂直 + 水平双重防越权

角色 role 值 能干啥
超级管理员 root 0 唯一能增删改用户,全部权限
管理员 admin 1 商品/订单/售后/智能中心
员工 staff 2 商品/订单/售后(不能动用户和智能配置)
客服 service 4 只管咨询工作台
顾客 customer 6 只能走 /c/ 接口

接口按前缀拆开:B 端 /b/、C 端 /c/ ,同一个 ProductService 给两端复用,但 C 端的查询不返回进货价这种敏感字段。

2.3 智能客服全流程(这是灵魂)

顾客在商城点「在线客服」,背后发生了什么?看时序图:

sequenceDiagram participant C as 顾客(h5) participant G as ChatGateway(socket) participant AI as AiChatService participant SDK as Claude Agent SDK participant MCP as MCP 工具(HTTP) participant API as 本地 REST API C->>G: customer:consult { scene } G->>AI: streamReply(text, customerId, onDelta) AI->>SDK: query({ tools:[], mcpServers }) Note over SDK: 寒暄直接回<br/>业务才调工具 SDK->>MCP: 调 c_order_list / c_knowledge_query... MCP->>API: fetch /api/c/order/list (带 x-acting-user-id) API-->>MCP: { code:0, data } MCP-->>SDK: 精简后的 JSON(剥离 base64 图) SDK-->>AI: stream_event 文本片段 AI-->>G: onDelta(片段) G-->>C: message:stream(打字机效果) SDK-->>AI: result(终值) AI-->>G: message:streamEnd(卡片 JSON) G-->>C: 渲染 markdown + 转人工按钮

几个关键设计点(都是踩坑换来的):

  1. AI 禁止碰数据库 。Claude 不能写 SQL、不能跑 Shell,所有数据只能通过 MCP 工具拿。这是硬性安全约束。
  2. MCP 工具本质是 HTTP 客户端 ,它不直接调 service,而是 fetch 本地 REST API。这么做是为了彻底干掉循环依赖(后面 3.x 详述)。
  3. 数据隔离 :C 端对话时把顾客 userId 通过 x-acting-user-id 头透传,MCP 只能查这个顾客自己的订单,杜绝水平越权。
  4. 转人工: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 透传给 antd Table
  • action: ['feed'] → 在卡片下方挂「点赞 / 踩」反馈按钮。

完整对话流程(同样出自设计文档):

  1. 点「智能助手」按钮弹窗,只能选 B 端的智能体 ;可随时切换智能体,切换时输入框、对话列表、对话详情都要绑定到正确的 agentId
  2. 左侧是对话列表 + 新建对话。新建对话传 agentId 调接口生成 sessionId;历史对话复用已有 sessionId
  3. 前端先调接口生成 chatId,再带着 query + chatId 请求 SSE 接口。停止生成有两种触发:用户点「停止」按钮,或直接再发一条新消息(都会调停止接口)。
  4. chatId 是「这一轮对话」的身份证 :不属于当前 chatId 的 SSE 数据一律丢弃------这样「停止上一轮 + 立刻发新一轮」时,旧流的残余帧不会污染新回复。

两端协议异曲同工:把「渲染什么 + 能交互什么」编码进一个 JSON,前端做纯粹的「卡片渲染器」。AI 想加个按钮、换个表格,改的是数据,不是前端代码。

2.5 总体架构

graph TD subgraph 前端 H5[h5 顾客商城<br/>UmiJS+antd-mobile] FE[fe 运营后台<br/>UmiJS+antd v6] end subgraph 后端 NestJS GW[REST 接口 /api] WS[WebSocket Gateway] AISVC[AiChatService] MCPSVC[McpService<br/>HTTP 客户端] DB[(MySQL via Prisma)] end SDK[Claude Agent SDK] H5 -->|axios /api| GW H5 -->|socket.io| WS FE -->|axios /api| GW FE -->|SSE fetch| GW GW --> DB WS --> AISVC AISVC --> SDK SDK --> MCPSVC MCPSVC -->|fetch + x-api-key| GW GW --> DB

注意那条自调用回环McpService 通过 HTTP 打回自己的 /api。看着绕,但它换来了「AI 工具层和业务 service 层零耦合」,循环依赖问题从根上消失。

🌟 这个项目的技术亮点(划重点)

如果只记住几件事,记这几个就够了:

  1. AI「零数据库权限」的硬隔离 :Claude 既不能写 SQL 也不能跑 Shell(tools:[] 清空内置工具 + canUseTool 白名单只放行 mcp__ebuy__*),取数的唯一通道是 MCP 工具。安全边界落在「能力层」,不是靠提示词嘴上说说。
  2. MCP 工具 = HTTP 客户端,自调用打回 /api :工具不直接调 service,而是 fetch 自己的 REST 接口。一招根除循环依赖,还让「AI 走的路」和「人走的路」是同一套接口、同一套鉴权。
  3. x-acting-user-id 透传实现数据隔离 :C 端对话把顾客 userId 透传给 MCP,AI 只能查到这个顾客本人的订单,水平越权从源头堵死;B 端则按当前登录运营的真实角色鉴权。
  4. 统一卡片协议 :C 端「机器/人工」对话共用一套 ICard,B 端用 state 状态机卡片(md/table),前端只做「卡片渲染器」,AI 想加按钮/换表格改的是数据不是代码。
  5. 智能体可配置 + 知识库可投喂 + 提示词可审计 :SOP 走后台配置不硬编码、知识库通过 knowledge_query 工具按需检索、再用 Claude 扮红队对提示词做安全审计。AI 能力被「驯化」成一个可运营、可治理的模块。
  6. 复用本机 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.tsconstants.ts(所有魔法值都收敛在这)。
  • AI 层 ai/ + mcp/:Agent SDK 接入、MCP 工具定义、智能体注册表、提示词安全检测。

2.7 h5 端(C 端顾客商城)

技术栈:UmiJS4 + antd-mobile v5 + mobx + socket.io-client,端口 3000。

  • 约定式全局布局 layouts/index.tsx + 底部 TabBar(首页/消息/购物车/我的)。
  • 状态管理 + 持久化全靠 mobxstores/ 下按域拆 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/servicereact-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 changeModaldestroyOnHidden(不是 destroyOnClose)、mask={{ closable: false }}(不是 maskClosable)。AI 经常写成 v5 的旧 API,得盯着改。

2.9 智能中心:让 AI「可配置、可投喂、可审计」

整个 AI 能力不是写死在代码里的,而是后台可运营的。这块由三个功能撑起来,是这个项目我最得意的部分:

graph LR subgraph 智能中心 A[智能体配置<br/>AiAgent] K[知识库<br/>KnowledgeBase] D[智能检测<br/>红队审计] end A -->|sopContent 提示词| AI[Claude Agent] K -->|knowledge_query 工具| AI D -->|审计+一键改良| A 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 第二步:按业务域「一个一个」推进,不要贪多

我的推进顺序大致是:

graph LR A[用户/登录/鉴权] --> B[商品] B --> C[钱包] C --> D[购物车] D --> E[地址] E --> F[订单/结算] F --> G[售后] G --> H[实时客服 socket] H --> I[AI 接入 SDK+MCP] I --> J[智能检测/B端助手]

每个模块都遵循同一个套路,我会给 AI 一个结构化的指令,比如:

「实现购物车模块。Prisma 加 CartItem 表(userId + productId 复合唯一),后端 c/cart 控制器,全 POST:add(已存在则数量累加)、list、updateQuantity、remove。注意水平越权:改/删要先校验归属。然后 h5 接上真实接口,重写 pages/cart。」

指令里包含了:数据模型、接口清单、权限约束、前端落点。AI 拿到这种指令,一次就能产出八九不离十的代码。

3.3 第三步:让 AI 自己验证(curl / tsc),而不是你手点

我几乎不手动测接口。每写完一个模块,我会让 AI:

  1. npx tsc --noEmit 做类型检查;
  2. curl 跑一遍核心链路(登录拿 token → 调接口 → 验返回 code);
  3. /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 / 解耦」,而不是硬上 ModuleRef hack。

坑 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 :用 makeAutoObservablehomeStore / mineStore / chatStore 等,App 启动时 init() 预加载一次,之后页面切换直接读 store 里的 observable,不再重新请求 ,只有用户「下拉刷新」才 refresh() 重拉。登录态则交给 userStore------构造时从 localStorage 读回、setLogin/logout 双向同步,刷新页面也不掉登录。

这一步带来两个收益:切 Tab 秒开(数据已在内存)+ 登录态持久化(刷新不丢) 。要注意 makeAutoObservable 只会把方法同步段包成 action,asyncawait 之后改 observable 必须用 runInAction 包一层,否则 mobx 会告警。

3.5 第四步:把「踩坑经验」沉淀成记忆

这是我觉得最爽的一点------AI 会把每次解决的坑写进记忆文件。下次遇到类似问题,它直接调取经验,不用重新踩。比如「Prisma 7 生成的 client 是 .ts 不能直接 node require,验证走 curl 最稳」这种细节,沉淀下来后效率直接起飞。

3.6 一个真实片段:连「安全审查」都让 AI 自己做

写到提交代码前,我让 AI 扫一遍有没有硬编码的账号密码。它自己 grep 出 seed.ts 里的 zlj@123deploy.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 把程序员的工作重心,从「写代码」挪到了「定义问题 + 把关质量」。

几点心得,送给想试的同学:

  1. 规则先行 。先和 AI 把 CLAUDE.md 这类规则文件定细,它跑偏的概率会断崖式下降。这比任何 prompt 技巧都管用。
  2. 小步快跑。一个业务域一个业务域推进,每步都让它自验(tsc + curl + swagger)。别指望一句话生成整个系统。
  3. 方向你来把,细节它来抠 。架构决策(比如「用 HTTP 解耦干掉循环依赖」「tools:[] 锁死内置工具」)需要你拍板;但具体实现、边界测试、回归验证,AI 比你勤快。
  4. 安全这根弦不能松。AI 默认会给自己开很大权限(能跑 Shell!),涉及 AI Agent 的项目,工具收口、数据隔离、提示词加固,一个都不能少。
  5. 让经验可复用。把踩过的坑沉淀成记忆 / 文档,AI 会越用越「懂你的项目」。

说到底,AI 不会取代你的判断力,但它能把你从重复劳动里解放出来。这个带智能客服的全栈电商系统,搁以前我一个人吭哧吭哧得写小半个月,现在我把更多时间花在了「想清楚要什么」上------而这,恰恰是最有价值的部分。


项目技术栈速览:NestJS 11 / Prisma 7 / Zod 4 / MySQL / socket.io / Claude Agent SDK + MCP / UmiJS4 / antd v6 / antd-mobile v5 / mobx。全程 pnpm,全程 AI 结对。

如果这篇对你有帮助,欢迎点赞收藏。有问题评论区见,我会尽量回 🙌

相关推荐
Awu12271 小时前
💡一个 \r引发的重试循环:AI Agent CLI 在 Windows 上踩的 CRLF 匹配病
agent
小白鼠幻想家1 小时前
你的 Agent 正在被 Prompt 注入:MCP 协议 RCE 漏洞深度拆解
agent
doiito2 小时前
【Agent Harness】Gliding Horse 设计细节 -- 不跟风开发自己的AI Agent
架构·rust·agent
小白鼠幻想家2 小时前
AI Coding Agent 在老代码面前集体翻车
agent
何智超2 小时前
AI 微前端性能优化之旅(上):复盘
前端·vibecoding
武子康3 小时前
调查研究-203 SpaceX IPO 总览:先别急着讲故事,先把发行事实和信息边界立住
人工智能·openai·agent
葫芦和十三12 小时前
图解 MongoDB 19|Oplog:复制的真正载体,不是文档是操作
后端·mongodb·agent
葫芦和十三12 小时前
图解 MongoDB 20|复制延迟与 catch up:Secondary 为什么跟不上
后端·mongodb·agent
冬奇Lab15 小时前
Workflow 系列(02):设计范式——四层架构、三种 Context 传递模式与确认门设计
人工智能·agent·工作流引擎