继续失业在家无所事事中,打算找点 Vibe Coding 能落地的实际业务,巩固一下后端和运维相关知识。看到之前认识的一摄影师哥们被公司集体裁员后,目前选择自己创业开影棚,于是在想能不能做一个这样的系统结合实际,于是便有了此项目。

前几天冲凉想到之后初步规划过,写了一份约 2500 多字的文档,随后让 AI 输出了一份完整的「产品设计文档」和对应项目的「技术说明文档」,我再核实确认修改了细节。本文仅作过程和踩坑记录,不包含具体 Prompt 和对应的业务细节。

项目背景

制作一个影棚管理系统,主要面向于摄影工作室场景开发,包含官网(作品展示、预约影棚等)、管理后台(管理员维护)、后端三个项目。

简要记录

今天的 Vibe Coding 已经完成制作了官网、服务端、后台三端的核心代码,期间也确实遇到不少问题,这里简单记录一下。模型主要是采用 GPT-5.5,部分流程自动降为 Cursor 的 Composer 2.5。

官网部分

官网方面,我首先是另外找 AI 对话探讨得到一套靠谱的设计,最终得到结果如下,其实从生成结果来看,整体还不错,就是顶上那个菜单风格真的是有点太熟悉了,哈哈。

整体风格:

- 高级感
- 沉浸式
- 年轻化
- 二次元审美
- 作品驱动
- 内容社区风格

避免:

- 企业官网风格
- 传统婚纱摄影网站
- 影楼宣传页
- 粉色二次元模板风
- 动漫论坛风格

参考风格:

- Pinterest
- Instagram
- Behance
- ArtStation
- 小红书
- Linear
- Raycast

视觉关键词:

- Dark Mode First
- Editorial Design
- Cinematic
- Immersive
- Premium
- Modern
- Elegant
- Gallery Focused
- Large Typography
- Minimal UI
- Rich Photography Showcase

后端部分

后端方面,问题主要是 Prisma 的接入相关的坑比较大,还引出了一个 ESM 和 TypeScript 打包配置的问题。

最开始遇到的问题是 PrismaClient 实例失效,直接报错找不到,排查后发现是 schema.prisma 的配置问题,需要指定 output 路径到当前项目下,它默认存的位置有问题,导致 TS 识别不到。这里基本对着 官方文档 接就能解决,AI 自己写的有问题。我自己写的是如下,只要放在当前项目内就行。

generator client {
  provider = "prisma-client"
  output = "./generated"
}

Prisma 接入方式从 7x 版本后发生了变化,包括 seed.ts 文件,需要手动传递对应的适配器包过去才行,AI 这里就搞晕了,用了旧版本的写法,我的解决方式是直接把最新文档的链接甩过去让它修改,问题真的解决了吗... 并没有!

import "dotenv/config";
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
// 这里必须写 .js 不然找不到文件
import { PrismaClient } from "./generated/client.js";
import * as bcrypt from "bcrypt";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
  throw new Error("DATABASE_URL is not set");
}

const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });

此时报错提示找不到模块(错误信息被删了我也找不到了)

这种 CommonJS 和 ESM 的打包问题,其实我自己也理解的不是很透彻,首先第一步 Prisma 要你在 package.json 里面增加 "type": "module" 就能使 NodeJS 认为当前包默认走 ESM 模式运行,因此你得确保构建后的产物也是 ESM 的代码(之前我有一个「小窝后端 Ext」项目是 KoaJS,用 ESBuild 构建成了 ESM 的 JS,就能直接运行,本质上就是 node index.js 这种执行方式),而 NestJS 自己有一套 CLI 工具,本质上是包装了一次 TSC,具体生成啥根据 type。说白了问题就这样解决:

  • ❌ Prisma 默认 ESM、NestJS 默认 CommonJS(项目没写 type=module
  • ✅ Prisma 强制成 CommonJS(配置 moduleFormat=cjs)、NestJS 默认 CommonJS
  • ✅ Prisma 默认 ESM、NestJS 也改成 ESM(项目写 type=module

Prisma 官方文档 的说明,就要求将包设置成 ESM 模式。

In package.json, add the type field set to "module"
{
  "type": "module"
}

我选择了这种方式,于是所有的 import 全部报错,都需要手动加入 .js 后缀才能正常访问。我看别人的项目没这个问题,为什么呢,后面单独列个说明。

NestJS 自己文档 中的描述则截然相反,要求将 Prisma 自己的输出构建为 CommonJS 模式...

Set moduleFormat in the generator to cjs

generator client {
  provider        = "prisma-client"
  output          = "../src/generated/prisma"
  moduleFormat    = "cjs"
}

The moduleFormat configuration is required because Prisma v7 ships as an ES module by default, which does not work with NestJS's CommonJS setup. Setting moduleFormat to cjs forces Prisma to generate a CommonJS module instead of ESM.

这就是集成第三方最坑的问题,双方各有其词,似乎就没有最正确的实践。

以上问题解决后,项目基本就正常跑起来了。

为什么要加 .js 后缀

对比配置后发现是 tsconfig.json 有两个关键配置:

{
  "module": "esnext",
  "moduleResolution": "bundler"
}

在我这个 NestJS 后端项目写的均是 nodenext。这两个到底啥区别,这是 AI 的解释:


第一个:前端打包器专用

module: esnext:编译输出原生 ESM 语法不降级,保留 import/export,不转成 CommonJS;仅给打包器(Vite/Webpack/Rollup)使用,不能直接丢给 Node 运行。
moduleResolution: bundler:TS 模拟打包器解析规则,适配前端工程习惯。

第二个:Node 原生运行专用

module: nodenext:跟随新版 Node 模块规范,自动根据 package.json type.mts/.cts 区分输出 ESM/CJS 产物,产物可直接 node dist/main.js 运行。
moduleResolution: nodenext:1:1 复刻 Node.js 原生模块解析逻辑,严格遵循 Node ESM/CJS 互操作规则。

Nest CLI 编译底层逻辑

nest start = nest build + 运行 dist 主文件;
nest build 底层调用原生 tsc 编译,完全遵循你 tsconfig.json 里的 module/moduleResolution 配置,Nest 不做特殊转译,仅做装饰器元数据收集。


问:也就是说第一种模式是走了一次打包器,需要打包器自己解决引入问题。而我这个是直接用 NestJS 的 CLI 直接构建输出了,它本身并没有打包的功能。是这样吗

1. 先分清楚两个概念:tsc 编译 vs 打包(bundle)

module: esnext, moduleResolution: bundler

不代表自动走打包器,只是让 TS 的类型解析规则模拟打包器(webpack/vite/rollup)

  • TS 本身只是转译 TS → JS,没有打包、没有文件合并
  • bundler 只是一套解析逻辑:允许省略 .js、自动识别 index、兼容前端库的导出字段;
  • 如果你只用 nest build(底层纯 tsc),不会触发任何打包,只会输出一堆零散 dist 文件。

所以你的理解一半对一半错:

正确部分:这套配置是给打包器环境设计的,依赖打包器运行时补齐导入路径、处理模块;
错误部分:TS / Nest CLI 本身不会自动调用打包器,只是类型校验规则对齐打包器。

② Nest CLI 原生能力

默认 nest build = 调用 tsc转译,不打包

  • 输入:零散 .ts 文件
  • 输出:dist 下一一对应的零散 .js 文件,没有代码合并、没有依赖内联;
  • nest start 只是 node dist/main.js 直接跑编译后的零散文件,全程无打包步骤。

Nest 只有一种情况会打包:你手动启用 webpack 构建(nest build --webpack),否则完全无打包逻辑。

2. 为什么 esnext + bundler 配 Nest 直接跑会崩

  1. tsc 输出纯 ESM 代码;
  2. 源码导入全是 import './utils'(不带 .js);
  3. 没有打包器介入补全后缀、解析路径;
  4. Node 原生 ESM 规则强制要求完整后缀,直接运行就报模块找不到;
  5. 且 dist 无 "type":"module" 时,node 会把文件当成 CommonJS,遇到 import 直接报错。

这套配置的设计初衷:

源码交给 vite/webpack 打包,打包器内部会处理路径补全、模块格式兼容,不会直接用 node 执行 tsc 输出的裸文件

3. nodenext 为什么适配 Nest(纯 tsc 编译后直接 node 跑)

moduleResolution: nodenext模拟 Node.js 原生模块解析规则

  • TS 在编译+类型检查阶段就强制你写 import './utils.js'
  • tsc 输出的产物完全符合 Node 运行标准;
  • 不需要打包器中转,nest start 直接 node dist 就能正常加载所有模块。

一句话总结你的疑问

  1. esnext + bundler 这套规则依赖打包器处理导入路径/模块兼容,但 Nest 默认 CLI 只是 tsc 转译、不带打包;
  2. 如果你不用 webpack 打包,只靠原生 nest build,就不能用 bundler 模式,否则编译产物无法直接 Node 运行;
  3. 后端 Nest 裸编译场景,正确配套是 nodenext(或传统 commonjs),而非 bundler。

所以解决办法无非就两个,要么继续走 nodenext 模式直接用(NestJS 官方默认 Starter 模板就是这个),每个文件携带 .js,要么用 bundler 自己配置走个打包器,就免除这个困扰了。