1. 项目概述t3code到底解决什么问题t3code这个名字乍一看像某个工具的代号其实它是我把一套完整的全栈项目实践流程打包后的内部代号。T3取自Three-Tier的缩写代表“三层架构”——前端展示层、后端服务层、数据存储层code就是代码本身。简单来说t3code的核心思路是用一套清晰的分层逻辑把一个从零开始的项目快速搭起来并且保证代码结构不乱、后期能维护、部署不头疼。很多人写代码的习惯是先写页面再写接口最后发现数据库表结构对不上前端改字段后端改参数来回拉扯好几天。t3code的初衷就是干掉这种“边写边乱”的尴尬局面。它不是一个现成的框架也不是某个npm包而是一种组织代码的方式——用固定的目录结构、约定好的接口规范、标准化的数据流转流程让一个项目从第一天起就走在正确的轨道上。这篇内容适合谁看我觉得有两类人收获最大刚入行的前端或后端开发想搞清楚一个完整的全栈项目到底怎么串起来已经在写业务代码但觉得项目越来越乱、改一处动全身想重构又不知道从哪下手的开发者。我后面所有讲的内容都是基于真实项目里踩过的坑和验证过的方案不是理论堆砌。你能直接照着结构开干也能拿来对比你现在项目的问题出在哪。2. 整体设计思路为什么用三层架构而不是一锅炖2.1 三层架构的本质是职责分离t3code的第一原则很简单每一层只做自己该做的事。前端只负责渲染和交互后端只负责业务逻辑和数据处理数据库只负责存储和索引。听起来像废话但真做起来90%的项目都做不到。我见过太多项目把SQL查询写在Vue组件里或者把业务判断逻辑堆在Controller层一来二去整个代码库变成一锅粥。你改一个订单状态的需求可能要同时动前端页面、后端接口、数据库触发器三个地方而且还不一定改得全。三层架构的好处在于它把修改范围限制住了。前端想改按钮样式你不需要碰后端后端想加一个字段校验你不需要动数据库结构数据库要加索引优化查询前端和后端完全无感。这种“互不干扰”的感觉在项目维护期尤其珍贵。2.2 为什么不用微服务或单体架构很多人一上来就纠结要不要用微服务要不要上K8s我给你的建议是先老老实实写好单体三层架构再去想分布式的事。微服务解决的是组织规模和团队协作的问题不是技术问题。你一个三五人的团队硬拆成十个微服务光服务间通信和链路追踪就能把你拖死。单体三层架构也许听起来不够“高级”但它简单、直观、容易调试。等你的业务真的到了单机扛不住、团队人多到代码冲突频繁的时候再考虑拆分也不迟。t3code的理念是用最小的复杂度解决当前的问题为未来的扩展留好接口但不过度设计。所以目录结构里会预留service层和repository层的边界以后就算要拆微服务你也知道哪些代码该搬到哪个服务里去。2.3 从一次需求迭代看数据流转假设你要做一个“用户下单”的功能。传统的一锅炖做法是前端点按钮直接往数据库插一条订单记录然后页面显示成功。看着很爽但后来你要加库存扣减、加优惠券校验、加支付回调这个“下单”就变成了一个大泥球。t3code的流程是这样的前端调用后端接口传入下单参数商品ID、数量、用户IDController层接收参数做基础的格式校验参数是否存在、类型是否正确Service层处理业务逻辑检查库存够不够、计算优惠金额、生成订单号Repository层负责和数据库交互查询库存、插入订单记录、更新库存数量数据返回给Service层再逐层返回给前端。每一步的职责边界非常清楚出了问题你一眼就能定位到是哪一层的锅。这就是t3code的核心价值。3. 项目结构搭建一套可以直接抄的目录规范3.1 后端目录结构以Node.js为例t3code的后端推荐使用Node.js Express或Koa因为JavaScript前后端统一学习成本低而且生态丰富。目录结构如下src/ ├── controllers/ # 接收请求处理参数返回响应 ├── services/ # 业务逻辑层核心代码都在这 ├── repositories/ # 数据访问层SQL和ORM操作封装 ├── models/ # 数据模型定义表结构映射 ├── middlewares/ # 中间件鉴权、日志、错误处理 ├── routes/ # 路由定义URL和Controller的映射 ├── utils/ # 工具函数加密、日期处理等 ├── config/ # 配置文件数据库连接、端口等 └── app.js # 入口文件关键点controllers层一定要“薄”。它的唯一职责是拿参数、调service、返回结果。所有if-else判断、数据计算、异常处理逻辑一律下沉到services层。这样做的原因是Controller是HTTP层的东西如果哪天你从HTTP换成了GRPC薄Controller可以直接丢弃service层代码原封不动复用。3.2 前端目录结构以Vue 3为例前端同样按照职责划分src/ ├── api/ # 接口请求封装axios请求、接口地址 ├── views/ # 页面组件路由对应的页面 ├── components/ # 公共组件按钮、弹窗、表格等 ├── stores/ # 状态管理Pinia/Vuex ├── router/ # 路由配置 ├── utils/ # 前端工具函数 └── assets/ # 静态资源图片、样式一个重要的约定是views里不要直接写axios请求。所有接口调用必须通过api目录下的文件统一管理。为什么因为这样当接口地址变了、或者要加统一的请求头时你只需要改一处。3.3 统一命名规范和数据字典t3code还强制统一命名规范接口路径统一小写中划线/api/user-order/list函数名统一驼峰getUserOrderList数据库表名统一小写下划线user_order字段名统一小写下划线user_name前端拿到的数据是驼峰userName数据库存的是下划线user_name中间需要一个转换层。这个转换可以在后端返回数据时统一处理用现成的库如camelcase-keys一键转换保证前后端协作时不用纠结字段名大小写的问题。4. 核心功能实现订单系统的完整代码拆解4.1 数据库表设计以订单模块为例我们需要三张表-- 用户表 CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, user_name varchar(64) NOT NULL COMMENT 用户名, phone varchar(20) DEFAULT NULL COMMENT 手机号, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL COMMENT 用户ID, total_amount decimal(10,2) NOT NULL COMMENT 总金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2已取消, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL COMMENT 订单ID, product_name varchar(128) NOT NULL COMMENT 商品名称, price decimal(10,2) NOT NULL COMMENT 单价, quantity int(11) NOT NULL COMMENT 数量, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我解释几个设计细节order_no设置为唯一索引防止重复下单订单号通过时间戳随机数生成也可以用雪花算法但在单体阶段简单的时间戳随机数就够用了金额字段用decimal(10,2)而不是floatfloat会有精度丢失的问题涉及钱必须用定点数状态字段用tinyint而不是字符串方便查询和索引状态含义在代码里用枚举或常量定义。4.2 Repository层代码数据访问的统一封装// src/repositories/orderRepository.js const db require(../utils/db); async function createOrder(order) { const [result] await db.query( INSERT INTO order (order_no, user_id, total_amount, status) VALUES (?, ?, ?, ?), [order.orderNo, order.userId, order.totalAmount, order.status] ); return result.insertId; } async function createOrderItems(orderId, items) { const values items.map(item (${orderId}, ${item.productName}, ${item.price}, ${item.quantity}) ); await db.query(INSERT INTO order_item (order_id, product_name, price, quantity) VALUES ${values.join(,)}); } async function findOrderByNo(orderNo) { const [rows] await db.query( SELECT * FROM order WHERE order_no ? LIMIT 1, [orderNo] ); return rows[0] || null; } module.exports { createOrder, createOrderItems, findOrderByNo };Repository层的特点是只做数据操作不做业务判断。比如findOrderByNo就是查记录至于查到以后订单是不是已经过期、能不能取消那是Service层的事。这样做的目的是让数据访问逻辑可以复用Service层要查订单时调用它定时任务要查订单时也调用它不用重复写SQL。4.3 Service层代码业务规则的核心// src/services/orderService.js const orderRepository require(../repositories/orderRepository); async function createOrder(userId, items) { // 1. 参数校验 if (!userId) throw new Error(用户ID不能为空); if (!items || items.length 0) throw new Error(商品列表不能为空); // 2. 生成订单号时间戳 用户ID 随机数 const orderNo ${Date.now()}${userId}${Math.floor(Math.random() * 1000)}; // 3. 计算订单总金额 const totalAmount items.reduce((sum, item) { return sum item.price * item.quantity; }, 0); // 4. 创建订单主表和明细表 const orderId await orderRepository.createOrder({ orderNo, userId, totalAmount, status: 0 }); await orderRepository.createOrderItems(orderId, items); return { orderId, orderNo, totalAmount }; } module.exports { createOrder };Service层的核心价值是把业务规则固化成可测试的纯函数。你不依赖HTTP请求也不依赖数据库连接只要传入正确的参数就能得到预期的结果。这也是为什么在t3code里Service层不允许直接使用req和res对象。我的一个实际操作心得Service层的函数最好保持“输入输出可预期”不要在里面写console.log。如果你想调试通过日志中间件统一记录这样线上排查问题的时候不会把日志打乱也让Service层代码保持干净。4.4 Controller层代码轻量代理// src/controllers/orderController.js const orderService require(../services/orderService); async function createOrder(req, res, next) { try { const { userId, items } req.body; // 基础校验 if (!userId || !Array.isArray(items)) { res.status(400).json({ code: 400, message: 参数格式错误 }); return; } const result await orderService.createOrder(userId, items); res.status(200).json({ code: 0, data: result, message: success }); } catch (err) { next(err); // 统一交给错误处理中间件 } } module.exports { createOrder };Controller层只做三件事解析参数、调用Service、返回统一格式的响应。错误处理通过next(err)交给全局中间件不在Controller里写try-catch这样接口的错误信息格式才能统一。4.5 统一响应格式和错误处理t3code项目里所有的接口响应都统一成这样的格式{ code: 0, message: success, data: {} }code 0表示成功非0表示失败data是业务数据没有数据时返回{}或nullmessage是给前端直接展示用的提示信息。全局错误处理中间件长这样// src/middlewares/errorHandler.js module.exports function (err, req, res, next) { console.error(Error:, err.message); const status err.status || 500; res.status(status).json({ code: status, message: err.message || 服务器内部错误, data: null }); };这个设计的好处是前端axios封装里只要判断res.code 0就进入成功回调否则弹message提示。不管后端哪个接口出错前端的处理逻辑都一样不用每种错误单独去处理。5. 前端工程化从页面到接口的一体化联调5.1 接口请求封装前端的api目录下统一封装axios实例// src/api/request.js import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000, }); // 请求拦截器携带token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器统一处理code request.interceptors.response.use( response { const res response.data; if (res.code 0) { return res.data; } if (res.code 401) { // 登录过期跳转登录页 window.location.href /login; return Promise.reject(new Error(登录已过期)); } ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } ); export default request;5.2 接口模块管理每个业务对应一个接口文件// src/api/order.js import request from ./request; export function createOrder(data) { return request({ url: /order/create, method: post, data }); } export function getOrderDetail(orderNo) { return request({ url: /order/detail/${orderNo}, method: get }); }统一封装的好处是接口地址集中在api目录后端接口变动时只需要改一个文件所有请求自动携带token不用每个接口单独写错误提示统一格式化不会出现“后端返回500但页面显示空白”的情况。5.3 Mock方案前后端并行开发的利器t3code项目里还推荐开发环境用Mock数据。前后端可以定义好接口文档后并行开发前端Mock数据先行等后端接口就绪后切换baseURL即可。Vite环境下推荐用vite-plugin-mock插件结合mockjs语法// mock/order.js import Mock from mockjs; export default [ { url: /api/order/detail/:orderNo, method: get, response: ({ query }) { return { code: 0, message: success, data: { orderNo: query.orderNo, status: 1, totalAmount: 99.90, items: [ { productName: 测试商品A, price: 49.95, quantity: 2 } ] } }; } } ];用Mock数据有个特别注意Mock的响应结构要和真实接口完全一致。不然前端写好了联调时发现字段对不上Mock就白做了。6. 容器化部署与持续集成6.1 用Docker编排前后端环境开发完成以后t3code使用Docker Compose来管理整套环境包括前端Nginx、后端Node应用、MySQL数据库。下面是一份可以直接用的docker-compose.ymlversion: 3.8 services: mysql: image: mysql:8.0 container_name: t3code-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: t3code MYSQL_USER: t3code MYSQL_PASSWORD: t3code123 ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql restart: always backend: build: ./backend container_name: t3code-backend depends_on: - mysql environment: DB_HOST: mysql DB_PORT: 3306 DB_USER: t3code DB_PASSWORD: t3code123 DB_NAME: t3code ports: - 3000:3000 restart: always frontend: build: ./frontend container_name: t3code-frontend depends_on: - backend ports: - 80:80 restart: always volumes: mysql-data:6.2 后端DockerfileFROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install --production COPY . . EXPOSE 3000 CMD [node, src/app.js]这里有几个优化点使用node:18-alpine镜像体积比标准镜像小很多先拷贝package.json再执行npm install利用Docker缓存层后续代码变更时不需要重新装依赖--production参数只安装生产依赖镜像体积进一步压缩。6.3 前端Dockerfile与Nginx配置FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location /api { proxy_pass http://backend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 解决前端路由刷新404问题 location / { try_files $uri $uri/ /index.html; } }Nginx的这段配置有两个关键点/api开头的请求反向代理到后端服务前端代码里不需要写上完整的后端地址try_files配置是SPA应用的标配否则用户刷新/order/123页面时会报404因为Nginx找不到这个路径对应的真实文件。6.4 CI流程自动化测试和部署t3code推荐在Git仓库里跑一套轻量级CI提交代码后自动完成“安装依赖→跑测试→构建镜像→部署到服务器”。用GitHub Actions举例name: Deploy on: push: branches: [ main ] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Use Node.js uses: actions/setup-nodev4 with: node-version: 18 - name: Install backend dependencies run: | cd backend npm install - name: Run backend tests run: | cd backend npm test - name: Build frontend run: | cd frontend npm install npm run build - name: Deploy to server run: | scp -r frontend/dist/* userserver:/usr/share/nginx/html/ scp -r backend userserver:/home/app/backend ssh userserver cd /home/app docker-compose up -d --build这里有一个经验教训不要把密钥直接写在workflow文件里。GitHub的Secrets功能把这些密钥加密存储在workflow里通过${{ secrets.SERVER_IP }}引用既能跑通流程也不会泄露密码。7. 测试与代码质量保障机制7.1 单元测试给核心逻辑上保险t3code要求Service层必须写单元测试。因为Service层是纯函数逻辑不依赖HTTP和数据环境测试成本最低收益最高。用Jest框架举例// test/orderService.test.js const { createOrder } require(../src/services/orderService); const orderRepository require(../src/repositories/orderRepository); jest.mock(../src/repositories/orderRepository); describe(createOrder, () { beforeEach(() { jest.clearAllMocks(); }); test(正常创建订单, async () { orderRepository.createOrder.mockResolvedValue(1001); orderRepository.createOrderItems.mockResolvedValue(null); const result await createOrder(1, [ { productName: 商品A, price: 10, quantity: 2 }, { productName: 商品B, price: 5, quantity: 1 } ]); expect(result.totalAmount).toBe(25); expect(orderRepository.createOrder).toHaveBeenCalled(); }); test(商品列表为空时抛出错误, async () { await expect(createOrder(1, [])).rejects.toThrow(商品列表不能为空); expect(orderRepository.createOrder).not.toHaveBeenCalled(); }); });写单测这件事最大的障碍不是技术而是心态。我自己的做法是核心业务方法至少覆盖一个成功路径、一个失败路径、一个边界条件。不用追求100%覆盖率但核心代码不写测试后面改需求的时候绝对心里发慌。7.2 前端接口联调的自检清单每次接口联调之前按这个清单自查一遍能避免一半以上的返工参数名是否统一前端传userName后端接收的是userName吗空值情况是否处理后端返回null时前端渲染会不会报错接口超时逻辑是否存在加载状态有没有显示列表接口的分页参数是否对齐page、pageSize字段名是否一致错误提示是否明确后端message的信息前端是否直接展示我见过最崩溃的联调场景是前端传了一个userId字符串后端要求是数字结果接口返回500后端查到凌晨才发现是类型问题。如果在接口文档里提前约定好每个参数的类型并且两端都用TypeScript定义接口数据结构这类问题可以直接在开发阶段暴露。7.3 代码审查的两个硬性标准t3code的Code Review只盯两件事Controller层是否足够薄如果Controller里出现超过5行业务逻辑打回重写。SQL是否都在Repository层如果Controller或Service里出现原生SQL字符串打回重写。这两个标准执行起来项目代码的整洁度就有了底线。我参与过很多项目评审代码乱不乱基本看这两条就能判断个大概。8. 常见问题与排查技巧实录8.1 接口联调时最常见的5个报错报错现象可能原因排查思路404 Not Found接口路径拼错对比路由定义和接口文档确认URL完全一致500 Internal Server Error后端逻辑异常查看后端日志定位异常堆栈401 Unauthorizedtoken缺失或过期检查浏览器DevTools的Request HeadersCORS跨域前后端域名不一致后端设置Access-Control-Allow-Origin或使用代理请求超时数据库查询慢或死锁查看SQL执行计划检查是否有慢查询8.2 数据库连接数打满的排查案例有一次测试环境的数据库连接数被打满后端接口大面积超时。排查过程如下show processlist查看当前连接状态发现大量Sleep状态的连接查看后端代码发现数据库连接池配置过大并且没有设置空闲回收时间定位到某个定时任务里db.query没有await导致连接被占用而无法释放修复连接池配置const pool mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 20, queueLimit: 0, // 关键参数空闲连接超时回收 idleTimeout: 60000, });排查这类问题的核心是先看现象再看日志最后看代码。不要一上来就重启服务那样能找到问题但永远找不到根源。8.3 前端刷新页面404的解决SPA应用部署后用户直接刷新子路由页面会404。典型的Nginx配置之前已经给出try_files $uri $uri/ /index.html。但如果是本地开发模式需要确保前端路由使用的是createWebHistory并且开发服务器配置了history fallback。Vite环境下默认支持Webpack环境需要加devServer.historyApiFallback true。8.4 一个容易被忽略的问题时区数据库存时间后端返回时间前端展示时间这中间稍有不慎就会差8个小时。t3code的约定是数据库统一存datetime类型按UTC时间存储后端统一返回ISO字符串前端统一用dayjs库转成本地时区展示。这样不管用户在哪个国家访问展示的都是当地时间后端存储的时间也是标准的UTC时间不会因为服务器位置不同而出错。9. 项目扩展方向与个人实战感悟t3code这套架构目前我把所有新项目都用它起步。说几个实际的好处新人接手项目看目录就能知道代码该写在哪里不需要老员工带两周测试的覆盖率可控核心逻辑有保障部署方式标准化换服务器基本无感代码审查有标准不用靠感觉去判断“写得好不好”。如果之后你想扩展可以在t3code基础上加Redis做缓存加RabbitMQ做消息队列加ElasticSearch做搜索。但因为三层的边界画得很清楚加这些组件只是横向扩展不会搞乱现有结构。我个人在实操中最大的体会是代码规范这东西写得出来不算本事能坚持执行几年不跑偏才算本事。t3code不是银弹它只是一套约束而已——约束你每一行代码放在该放的位置约束你每个接口响应格式统一约束你每次上线前想清楚怎么回滚。这些约束在项目初期会有点“麻烦”但等到项目到了一定规模你会感谢这些麻烦。最后分享一个我踩过的坑别把t3code的结构硬套到所有项目上。如果你做的是一个小工具、一个脚本、一个POC验证三层架构确实是杀鸡用牛刀。灵活判断什么时候该精简、什么时候该规范这本身就是经验的价值。