1. 项目概述
1.1 为什么会做一个AI算力资源商城
这两年生成式AI、大模型训练、微调、推理部署的项目越来越多,不管是做独立的AI产品,还是企业内部做智能应用,都绕不开GPU算力。但实际接触一圈就会发现,个人开发者或者中小团队想买算力,渠道其实挺难受的:要么去公有云官网按实例下单,页面复杂、计费项多,看着头晕;要么找第三方渠道租赁,但价格不透明、交付周期看运气;还有很多人用的是共享GPU,邻居一个任务就能把显存挤爆,训练中途直接OOM。
所以当时我给自己定了一个目标:做一个面向AI开发和科研场景的算力资源商城系统,把GPU云服务器、训练任务、推理API这类数字商品像普通电商一样上架、选购、下单、支付、自动交付。用户打开网页,明码标价看到不同显卡配置,选好规格和时长,下单选卡,系统自动开通资源,全程像逛淘宝一样顺畅。这个项目我用的是Node.js + Vue3这套技术栈,前后端分离,整体做下来无论是业务建模还是工程落地,都有不少值得复盘的地方。
这篇文章就完整拆解一下这个系统的设计与实现,包括技术选型、数据模型、核心模块、前端交互、部署避坑,以及我踩过的比较典型的坑。适合正在做全栈项目、想了解数字商品交易系统如何设计、或者准备把AI算力资源平台化的开发者参考。如果你是刚学完Node.js和Vue3想找个综合项目练手,这篇文章同样可以参考。
2. 整体设计与技术选型分析
2.1 为什么选Node.js而不是Java或Go
后端选型的时候我其实纠结过一阵,毕竟市面上主流的电商系统几乎都是Java Spring Cloud的天下,Go在云原生领域也很有话语权。但考虑到这个项目的实际定位,最后我还是定了Node.js。
一个核心原因是团队技术栈统一。前端用Vue3,必然要写大量JavaScript/TypeScript;如果后端也统一到Node.js上,那前后端可以共享类型定义、接口契约、工具函数,联调成本会低很多。比如我封装了一个createApiClient,前端和后端引用同一份.d.ts类型声明,改接口字段时编译期就能暴露问题,这一点在快节奏迭代中非常香。
另一个原因是Node.js在I/O密集场景的表现足够好。算力商城这个业务,大量的操作其实是“转发”和“编排”:用户下单,系统要调用云服务商API去开通实例、查询状态、推送日志,还要在WebSocket上推送实时进度。这些都是典型的I/O密集型任务,Node.js的事件循环模型天然适合。实际压测下来,单机Node.js处理2000个并发下单请求没有出现瓶颈,对商城项目来说完全够用。
当然,如果业务规模真的到了千万级订单量,要上分布式事务、复杂金融级对账,那Java和Go的生态确实更成熟。但那是后话,对于起步阶段的算力资源商城,Node.js能让我用最小的成本跑通整个业务闭环,这就够了。
2.2 为什么前端选择Vue3 + Vite + Pinia
前端用Vue3几乎是顺理成章的选择。Vue3的组合式API(Composition API)比Options API更适合商城这种功能复杂的业务页面,因为它可以把同一个业务维度的逻辑聚合在一起。举个例子,购物车页面里计价、优惠、库存校验、结算按钮状态,这四块逻辑在Options API里可能要分散到data、computed、methods三个区域,但在组合式API里我可以封装一个useCart组合函数,所有购物车相关状态和操作一目了然。
工程化这块我用的是Vite。Vite的开发服务器基于原生ES Module,冷启动速度比Webpack快一个数量级,改代码后的热更新也是毫秒级。这个项目组件数量上百,如果用Webpack,每次保存都要等两三秒,开发体验会差很多。而且Vite对Vue3的支持是官方一等公民,插件生态也齐全,不需要额外的兼容层。
状态管理我选的是Pinia。Vuex 3.x是为Vue2设计的,Vuex 4虽然支持Vue3但设计上还是老一套;Pinia则完全按照Vue3的响应式原理重写,去掉了mutations的概念,直接在action里修改state,代码量少了接近一半。在我这个商城项目里,登录用户信息、全局购物车数量、资源开通状态这些跨页面共享的状态,用Pinia管理非常顺手。
2.3 商城系统的整体架构和模块边界
整个系统采用前后端分离架构,前端部署在Nginx静态服务器上,后端是Node.js服务,数据层用MySQL + Redis。
前端 Vue3 + Vite + Pinia + Element Plus | |--- HTTP/WebSocket ---> | 后端 Node.js + Express + TypeScript | |--- ORM ---> MySQL(商品、订单、用户) | |--- Redis(购物车缓存、分布式锁、验证码) | |--- 云服务商API(开通实例、查询状态) | |--- 对象存储(商品图片、资源快照)后端按业务域拆成六个模块:用户模块(注册登录、实名认证、API密钥管理)、商品模块(算力规格管理、库存状态、价格策略)、订单模块(下单、计价、支付回调)、交付模块(实例开通、状态同步、销毁释放)、钱包模块(余额充值、流水记录、发票申请)、运维模块(后台管理、日志、告警)。
这个模块边界的划分是我比较满意的部分。传统电商项目容易把“订单”和“交付”揉在一起,但算力商城不一样,支付完成后交付是异步的,可能需要几十秒甚至几分钟才能把GPU实例开通好,所以订单和交付必须彻底解耦,用订单状态机来驱动整个流程。
2.4 为何把算力资源设计成可上架的数字商品
一开始我考虑过两种商品模型。第一种是把算力资源当成服务,用户提交工单申请,管理员线下开通后告知用户连接信息;第二种是把算力资源当成标准化数字商品,用户在线选择规格、自动计价、自助下单、系统自动交付。
第一种模型的开发量小很多,但使用体验太差。用户下单后不知道要等多久,也不知道进度;管理员手工开通容易出错,高峰期容易漏单。第二种模型虽然前期开发成本高,但一旦跑通,用户不需要跟任何人沟通,从下单到拿到SSH连接信息全是自动化的,这是成了一个平台该有的样子。
最后我选择了第二种,并且设计了一套适配数字商品的SKU模型。举一个例子,一张“RTX 4090云主机”的商品卡片,在数据库里不是一个简单字段,而是一组参数的组合:
{ "id": "sku_4090_24g", "name": "RTX 4090 云主机", "spec": { "gpu_type": "RTX 4090", "gpu_count": 1, "vram_gb": 24, "cpu_core": 8, "ram_gb": 64, "disk_gb": 200, "bandwidth_mbps": 100, "region": "cn-east-1" }, "settlement": { "charging_mode": "hourly", "price_per_hour": 3.5 }, "stock": { "total": 20, "available": 13 } }商品的基础规格用spec字段保存,SKU之间通过规格差异来区分。这样用户在商品详情页选GPU型号、选区域、选时长时,前端可以根据spec的组合动态计算价格,后端根据stock.available做库存扣减,逻辑既不复杂,又能适配未来扩展更多算力型号。
3. 数据模型与核心业务设计
3.1 数据表设计与商品SKU模型
数据库设计是整个系统的地基,这块我前前后后改了四版才算稳定。主要核心表如下:
user:用户表,包含手机号、邮箱、密码哈希、余额、状态等字段。gpu_product:算力商品表,类似SPU(标准产品单元),比如“RTX 4090云主机”“A100训练集群”。gpu_sku:货品表,类似SKU(库存量单位),关联SPU,并通过参数区分不同配置。cart:购物车表,记录用户加购的SKU、数量、时长、规格快照。order:订单主表,记录订单号、用户ID、总金额、优惠金额、实付金额、订单状态。order_item:订单明细表,关联具体SKU、价格、时长、规格快照。instance:算力实例表,记录交付出来的云主机信息(公网IP、SSH端口、系统镜像、到期时间)。delivery_task:交付任务表,记录下单后每个实例的开通进度和状态。wallet_transaction:钱包流水表,记录充值、消费、退款等资金变动。
商品和SKU为什么要分开两张表?因为一个商品下会挂多个SKU,比如“RTX 4090”下面有“按小时计费”和“包月计费”两个SKU,它们的用户价值本质上是一个商品,但不通规格的库存和价格都不同。SPU和SKU分离之后,商品详情页的整体信息存放在SPU表,SKU表存具体规格和库存,后端通过SPU ID一次性查出所有SKU,前端渲染会非常高效。
3.2 订单状态机与关键状态流转
订单状态是整个系统的核心枢纽,这个状态机设计得好不好,直接决定了后续开发和排障的复杂度。我的订单状态流转如下:
待支付 -> 支付成功(待交付) -> 交付中 -> 运行中 -> 已过期/已释放 | | | +---> 已关闭(用户取消) +-------> 已关闭(超时未支付)状态之间是单向不可逆的,任何状态变更都必须有对应的操作记录(日志)。即使同一个状态位被错误提交,因为有前置状态校验,也不至于跳到不该去的地方。
这里有个小细节:订单超时关闭用的是Redis延迟队列而不是每秒扫表。用户下单后会拿到一个订单号,同时在Redis的order_expire有序集合里写入过期时间戳,后台起一个定时任务每10秒扫描一次,把过期未支付的订单关掉并释放购物车库存。这个方案比每秒全表扫描订单表效率高太多了,也不会给MySQL带来无谓压力。
3.3 计价策略与分布式锁防超卖
算力资源的计价比普通商品复杂不少。普通商品是单价乘以数量,算力资源还要乘以时长,并且时长越久折扣越大。我设计了三个计价参数:
- 基础单价:按小时计费的单价
- 时长系数:按天计费是小时价的22倍左右(相当于约9折),按月是小时价的480倍左右(相当于约67折)
- 阶梯折扣:一次性付费满一定金额后打折
最终计算公式为:
总金额 = 基础单价 × 时长(小时) × 时长系数 × 阶梯折扣折扣逻辑由后端服务统一计算,前端展示的实时价格只是调用后端的计价接口,避免前端改价、下单金额和实际支付金额不一致的问题。所有订单金额到元为止,采用toFixed(2)后存储DECIMAL(10,2)类型。
防超卖是商城系统的经典问题,我用Redis的DECR命令做库存扣减。下单时先把SKU预扣(DECR sku_stock:{skuId}),如果返回负数说明库存不足,直接提示用户并回补。真正支付成功后才走正式的库存扣减,超时未支付则回补。这个方案的优点是原子性有保障,不会出现两个请求同时读到库存为1然后都下单成功的情况。
3.4 交付模块:从订单到算力实例的自动闭环
这是整个系统最特别的地方,也是算力商城和普通商城最大的区别。普通商品支付完就完事了,算力资源支付完之后还有个“生产”过程——后端要去云服务商那边开通一台真正的云主机,然后才能把连接信息返回给用户。
交付模块我设计成了类似的异步任务流水线:
- 用户支付成功,订单状态变为“待交付”。
- 后端创建
delivery_task,状态为pending。 - 交付任务调度器取出任务,调用云服务商API创建云主机实例。
- 轮询查询实例状态,等它变为
running后获取IP、SSH端口和初始密码。 - 更新
instance表,写入连接信息,把订单状态变为“运行中”。 - 如果中途失败,任务重试最多3次,仍失败的进入死信队列,由运维人员报警介入。
这个过程中最坑的是云服务商API开通实例的响应时间不稳定,短则30秒长则5分钟。所以我用了异步任务轮询,而不用HTTP同步等待,否则用户的支付请求一直卡在那里,Nginx反代都要超时了。前端通过WebSocket接收交付进度推送,页面实时展示“开通中 40%”之类的内容,互动感好很多。
4. 核心功能模块实现与关键代码
4.1 用户注册登录与JWT鉴权
用户模块用了JWT做无状态身份认证。注册时对密码做bcrypt哈希存储,登录成功后签发Access Token和Refresh Token。Access Token有效期2小时,Refresh Token有效期7天,前端把Token存在内存变量和localStorage里,每次请求在Axios拦截器里带上Authorization: Bearer <token>。
// 后端核心代码:JWT签发与验证 import jwt from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET || 'your-secret'; export function signToken(payload: Record<string, any>, expiresIn: string) { return jwt.sign(payload, JWT_SECRET, { expiresIn }); } export function verifyToken(token: string) { try { return jwt.verify(token, JWT_SECRET); } catch (error) { return null; } }有一个我踩过的坑:JWT是无状态的,一旦签发,在有效期内后端无法主动让它失效。如果用户想退出登录,单纯前端删掉Token,原来的Token其实还是可用的。所以我额外在Redis里维护了一个黑名单,退出登录时把Token的jti(唯一ID)写入黑名单并设置和Token一致的过期时间,每次请求校验Token时先查一下黑名单。虽然增加了一次Redis查询,但安全性提升很多。
4.2 商品列表与SKU动态选择
商品列表页是用户了解算力资源的第一入口。前端通过后端接口拉取SPU列表和对应的SKU列表,渲染成商品卡片。用户点进详情页后,会看到GPU型号、显存、CPU核数、内存、带宽、区域等多个可选项,这些选项组合起来就是一套SKU。
SKU选择器的核心逻辑是:每次用户更改某个规格选项,都要重新计算当前可选的规格组合。简单说,如果我锁定了“RTX 4090”和“cn-east-1”,那“按小时”“按天”“包月”这三个计费方式如果都有货,就全部可选;如果“按天”对应的SKU库存为0,则置灰不可选。
<!-- 前端Vue3核心代码:SKU选择器简版 --> <script setup lang="ts"> import { computed } from 'vue'; const props = defineProps<{ skus: SkuItem[]; selected: Record<string, string | number>; }>(); const emit = defineEmits<{ (e: 'change', payload: Record<string, string | number>): void; }>(); // 计算当前已选规格组合 const currentSku = computed(() => { return props.skus.find((sku) => { return Object.entries(props.selected).every( ([key, value]) => sku.spec[key] === value ); }); }); function selectOption(key: string, value: string | number) { emit('change', { ...props.selected, [key]: value }); } </script>这里的技巧在于:SKU匹配不能简单用===整个对象对比,因为selected可能只锁定了一部分维度,还有维度没选定。所以判断“当前选中是否构成一个有效SKU”时,要用every逐个字段比对,只要所有已经选定的维度都匹配,就算是一组候选组合。当前Sku一旦匹配成功,就可以动态计算总价、库存状态和“立即购买”按钮的可点击状态。
4.3 购物车与实时计价
购物车的设计参考了主流电商的做法:未登录用户用本地存储(localStorage)保存购物车,登录后可以把本地购物车合并到服务端购物车。合并时以服务端购物车为基准,但本地新增且服务端不存在的SKU要追加进去。
购物车实时计价是比较考验前端响应式和后端接口配合的。购物车列表里每一项都有“购买时长”这个输入,而价格又是按小时单价乘以时长计算的,所以只要用户改一个时长,整个购物车的汇总金额就要重新计算。我是这样实现的:
// 前端Vue3核心代码:购物车汇总计价 import { computed, ref } from 'vue'; const cartItems = ref([]); const totalAmount = computed(() => { return cartItems.value.reduce((sum, item) => { const lineAmount = item.unitPrice * item.quantity * item.hours; return sum + lineAmount; }, 0); });减少不必要的渲染,这里用到了computed的缓存特性。购物车组件里多个区域都在引用totalAmount,但因为它是响应式依赖,只有cartItems变化时才会重新计算,并不会因为组件重渲染而重复执行。实际渲染数量几十个SKU时完全无压力。
服务端购物车在Redis里用Hash存储,每个SKU对应一个字段,值是JSON字符串,包含数量、时长、规格快照。下单时直接读取整个Hash,转为订单明细,然后清空购物车。用Hash的好处是用户可以快速更新单个SKU的购买时长,而不需要动整个购物车对象。
4.4 支付流程与回调签名校验
支付是这个项目里代码量最大、坑也最多的一个模块。由于是个人项目,我没有接那种需要企业资质才能开通的支付渠道,而是用了一个模拟支付网关,但整个流程完全参考真实支付系统的回调规范。
支付流程如下:
- 用户提交订单,后端创建订单并返回订单号和支付参数。
- 前端跳转到模拟收银台页面,用户点击“确认支付”。
- 模拟网关收到支付请求,生成一笔待确认的交易,然后向商城后端发送异步通知回调。
- 后端收到回调,验证签名和金额,确认无误后更新订单状态为“已支付”,并触发交付任务。
- 前端通过WebSocket或轮询感知订单状态变化,跳转到“支付成功”页面。
签名校验是支付安全的核心。模拟网关用商户密钥对order_id、amount、timestamp生成一个MD5签名,回调时后端用同样的密钥和字段顺序重新计算签名,如果算出的签名和回调里的签名不一致,直接拒绝这笔回调。还要校验金额严格一致,防止有人伪造回调说付了钱。
// 后端核心代码:支付回调签名校验 function verifySign(params: Record<string, string>, sign: string): boolean { const secret = process.env.PAY_SECRET || ''; const filtered = Object.keys(params) .filter((key) => key !== 'sign') .sort() .map((key) => `${key}=${params[key]}`) .join('&'); const expected = crypto.createHmac('md5', secret).update(filtered).digest('hex'); return expected === sign; }注意这里有一个细节:参与签名的参数必须排序,否则字段顺序变了签名结果就不同,导致真实的回调也被误判为伪造。我用的方式是按参数名的字母序排序后再拼接,这是业内常见的做法,你们做真实支付对接时也要遵循商户平台给的签名规则。
同时,回调处理接口必须实现幂等。支付网关可能会因为网络重试给同样的回调发多次,如果每次收到回调都去更新订单状态,第二次就会把状态从“已支付”改成“已支付”,虽然不报错但白白造成一次多余的update。更重要的是,如果出现并发回调(两个请求同时到了),可能都会判定可以进入交付流程,导致交付任务被创建两次。所以我在处理回调时用了数据库唯一订单号作为分布式锁的key,只有获取到锁的请求才能执行状态流转,另一个请求直接返回“已处理”。
4.5 算力实例管理与到期自动释放
用户支付完成后拿到了实例连接信息,接下来就是实例管理:查看实例状态(运行中/已停止/已到期)、连接信息、监控数据、续费、强制关机、销毁释放。
这个模块的核心是到期释放。我在实例表里维护了expire_at字段,后台跑一个定时任务每分钟扫一次,把到期的实例执行释放操作:先通知云服务商销毁实例,然后更新本地状态为“已释放”,同时生成一笔钱包流水记录资源退回的押金(如果有)。
有一个业务细节:到期释放前7天、3天、1天各发一次站内信和邮件提醒,避免用户资源突然没了影响实验。这个提醒功能用了一个简单的定时任务,扫描expire_at减去当前时间落在提醒时间窗口内的实例,然后异步推送消息。一开始我是在每个用户请求时顺带检查有没有到期提醒,后来发现数据量大了效率不行,改成定时任务后好多了。
5. 前端Vue3实现与体验优化
5.1 组合式API组织商城业务逻辑
商城前端的业务逻辑非常复杂,如果全部堆在组件里,代码很快就没法维护了。我的做法是用组合式函数(Composable)把业务逻辑拆出来,组件只负责模板渲染和事件绑定。
以商品详情页为例,我封装了useProductDetail、useSkuSelector、usePricing三个组合函数。useProductDetail负责拉取商品信息、评论列表、关联推荐;useSkuSelector管理SKU选择状态和可用性计算;usePricing监听SKU选择结果,调用后端计价接口返回实时价格。
// 组合式函数示例:usePricing import { ref, watch } from 'vue'; export function usePricing(selectedSku, quantity, hours) { const price = ref(0); const loading = ref(false); async function fetchPrice() { if (!selectedSku.value) return; loading.value = true; try { const { data } = await api.get('/api/sku/price', { params: { skuId: selectedSku.value.id, quantity: quantity.value, hours: hours.value, }, }); price.value = data.price; } finally { loading.value = false; } } watch([selectedSku, quantity, hours], fetchPrice, { immediate: true }); return { price, loading }; }这种组织方式的收益在后期维护时非常明显。比如我想在商品详情页新增一个“邀请好友立减”的活动,只需要新建一个usePromotion组合函数,然后在组件里调用并绑定到现有价格面板上,完全不用动原来的计价逻辑。组件代码保持轻薄,问题定位也快。
5.2 组件化设计:SKU选择器、资源卡片、订单状态标签
组件化的核心目标是复用,但这个项目里我最大的体会是:拆分组件不是越多越好,而是要把“变化点”和“稳定点”分开。商城页面的变化点主要集中在商品展示形式、SKU交互方式、订单状态展示样式上,这些我抽成了独立组件;而变化少的用户信息、页面框架就尽量保持稳定,不强行拆分。
SKU选择器是我花最多心思的组件。它的交互复杂度在于规格联动和可用性置灰。比如用户选了“RTX 4090”后,区域下拉里“cn-east-1”有货但“cn-west-1”没货,后者就要置灰并提示“该区域库存不足”。这个逻辑需要后端返回每个SKU的库存状态,前端构建一个规格映射,每次选择变化时重新计算可选项。
资源卡片组件也做了适配。商品列表页的资源卡片和用户控制台的实例卡片,展示的数据结构不同,但外观风格要保持统一。我让资源卡片组件接收一个typeprop,值为product时渲染库存和价格,值为instance时渲染运行状态和到期时间。这样一套组件两处用,没有复制粘贴任何模板代码。
订单状态标签组件我做了更细的拆分。不同状态(待支付、已支付、交付中、运行中、已过期)要显示不同的颜色、图标和辅助文案。这个组件不承载任何业务逻辑,只根据status字段映射样式和文案,纯粹是一个展示组件,好处是全站订单状态展示风格统一,后续就算改配色也只动一个文件。
5.3 购物车与订单确认页的响应式体验
购物车和订单确认页是全项目交互最复杂的页面,因为它们涉及大量“选择联动”。用户在订单确认页选择支付方式、输入优惠码、调整购买时长,页面底部的支付金额面板要实时刷新;切换支付方式时,如果选择了余额支付,还要显示当前余额并校验是否足够。
响应式的核心设计是:订单确认页维护一个orderDraft对象,包含SKU列表、数量、时长、优惠码、支付方式等所有订单参数;页面所有区域的数据都从orderDraft派生计算,任何交互都只是修改orderDraft的某个字段。
// 前端核心代码:订单确认页响应式结构 const orderDraft = reactive({ items: [], couponCode: '', paymentMethod: 'balance', remark: '', }); const payableAmount = computed(() => { const subtotal = orderDraft.items.reduce( (sum, item) => sum + item.linePrice, 0 ); const discount = couponDiscount.value; return Math.max(0, subtotal - discount); }); const canSubmit = computed(() => { return ( orderDraft.items.length > 0 && payableAmount.value >= 0 && paymentValid.value ); });这里有个很关键的体验点:金额计算必须完全由前端承担还是后端兜底?前端的实时计算只是展示式的,真正的扣费以后端订单数据为准。但前端计算出错会给用户带来困惑,比如后端说优惠10元前端显示优惠5元。所以我的优惠码计算逻辑前后端是同一套算法(按百分比或固定金额),后端是唯一权威,前端只是用相同逻辑做即时反馈。提交订单时后端的完整计价会覆盖前端的临时计算结果。
5.4 交付进度实时展示与WebSocket连接
支付成功后的交付过程通常需要几十秒,如果让用户干等一个“处理中”的转圈,体验很差。我接入了WebSocket,在创建订单时前端建立连接并订阅订单ID对应的频道,后端交付任务每推进一个阶段就推送一条进度消息(如下),前端更新进度条、步骤条和日志区域。
{ "type": "DELIVERY_PROGRESS", "orderId": "202501081234560001", "stage": "CREATING_INSTANCE", "stageName": "正在创建云主机", "progress": 40, "message": "已调用云服务商API,等待实例启动..." }WebSocket连接管理有几个细节需要注意。一是连接要按订单绑定,用户同时下了多个订单,收到的消息不能串台;二是连接断开后要自动重连,避免用户等待到一半收到不了进度;三是用户离开订单详情页时必须主动关闭连接,否则连接数会越积越多。我用Vue3的onUnmounted钩子里关闭连接,同时后端对长时间空闲的连接做了心跳检测,超时自动断开。
// 前端核心代码:WebSocket连接管理简版 const ws = ref<WebSocket | null>(null); let reconnectTimer: number | null = null; function connect(orderId: string) { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; ws.value = new WebSocket(`${protocol}://${location.host}/ws/order/${orderId}`); ws.value.onmessage = handleMessage; ws.value.onclose = () => { reconnectTimer = window.setTimeout(() => connect(orderId), 3000); }; } function handleMessage(event: MessageEvent) { const data = JSON.parse(event.data); if (data.type === 'DELIVERY_PROGRESS') { progress.value = data.progress; stageName.value = data.stageName; } }5.5 倒计时、轮询与内存泄漏排查
商城系统里有大量定时操作,比如支付倒计时、交付进度轮询、优惠活动倒计时。这些功能做起来简单,但最容易出问题的是内存泄漏和定时器没清理。Vue3组件卸载后,如果一个setInterval没有清除,它会永远运行下去,持续执行回调里的DOM操作,轻则报警告重则页面卡顿。
我封装了一个useCountdown组合函数,在组件卸载时自动清理定时器:
import { onUnmounted, ref } from 'vue'; export function useCountdown(seconds: number) { const left = ref(seconds); const timer = ref<number | null>(null); function start() { if (timer.value) clearInterval(timer.value); timer.value = window.setInterval(() => { left.value -= 1; if (left.value <= 0) { clearInterval(timer.value as number); timer.value = null; } }, 1000); } onUnmounted(() => { if (timer.value) clearInterval(timer.value); }); return { left, start }; }另一个坑是轮询接口没有加“幂等请求”保护。比如交付中状态每5秒请求一次查询进度,如果一次请求的响应时间超过5秒,就会造成多个并发的查询请求同时在跑,服务端压力大,返回的数据也可能乱序。我在Axios层加了全局的重复请求取消机制,当同一URL和相同参数的请求还在等待响应时,新发起的同参数请求会被忽略或取消,只在完成之后才允许下一次请求。
6. 环境配置、部署上线与常见问题排查
6.1 Node.js与Vue3环境配置
这个项目是Node.js 18+和Vue3组合,环境配置本身不复杂,但对于不少初学者来说,第一步就容易被拦住。Node.js安装时要注意:安装路径不要带中文和空格,否则后续npm install和node-gyp编译原生模块会各种报错。我见过太多人把Node装到C盘带空格的路径里,然后npm报权限错误,折腾半天。
安装完成后验证环境:
node -v npm -v如果版本号正常显示,就说明Node.js和npm都装好了。Vue3项目用Vite创建,不需要全局装Vue CLI,直接使用npm来创建:
npm create vite@latest ai-compute-mall -- --template vue-ts cd ai-compute-mall npm install npm run dev这里有一个高频问题:npm在Windows PowerShell下会报“不识别npm命令”或“因在此系统上禁止运行脚本”的错误。如果输入npm后提示“无法加载文件...ps1,因为在此系统上禁止运行脚本”,这是PowerShell的执行策略问题,不是npm没装好。解决办法是管理员权限打开PowerShell执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser这个命令的意思是允许本地脚本运行,远程未经签名的脚本仍然会拒绝,安全性是可控的。改完后重新打开终端,npm命令就能正常使用了。
6.2 前后端API联调与跨域配置
开发环境前后端分离,前端跑在5173端口,后端跑在3000端口,必然遇到跨域问题。解决跨域最规范的方式不是在前端代理里把API地址写死,而是让后端正确配置CORS中间件。
// 后端核心代码:CORS配置 import cors from 'cors'; const allowedOrigins = [ 'http://localhost:5173', 'https://your-domain.com', ]; app.use( cors({ origin: allowedOrigins, credentials: true, }) );如果前端请求里带了Cookie或者Authorization头,后端就必须设置credentials: true,并且origin不能使用*通配符,必须明确指定允许的来源。这是很多新手容易踩的坑,配置*后普通接口没问题,但带鉴权的请求会被浏览器拦截。
生产环境的反替代方案是:前端构建成静态文件部署在Nginx,后端跑在Node进程里,Nginx把/api路径反向代理到后端服务,这样前后端同源,不需要CORS。配置如下:
server { listen 80; server_name your-domain.com; root /var/www/ai-compute-mall/dist; index index.html; location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }6.3 生产环境部署:PM2守护与HTTPS
生产环境我用PM2守护Node.js后端进程。PM2的优势是进程崩溃自动重启、支持集群模式多实例运行、自带日志管理和监控面板。启动命令:
pm2 start dist/index.js --name ai-compute-mall --instances 2 pm2 save pm2 startup--instances 2表示启动两个Node进程,利用多核CPU提升并发能力。这里要注意,如果应用里使用了Redis或者数据库连接池,多进程模式不会自动共享这些连接,需要合理配置连接池大小,避免每个进程都维护一堆空闲连接把数据库打满。我实际用的是连接池上限20,两个进程足够用了。
HTTPS证书我用的Let's Encrypt免费证书,Nginx配置好证书记录后每三个月自动续期一次。对商城系统来说,HTTPS是强制项而不是可选项,因为用户的登录凭证、支付信息都通过加密通道传输,明文HTTP很容易被中间人截获。另外,现代浏览器的navigator.geolocation、crypto.subtle等API也只有在HTTPS环境下才能正常调用,将来如果要接入指纹识别或者密码学相关的功能,没有HTTPS会有很多限制。
6.4 典型报错排查思路与解决记录
项目开发过程中我记录了一堆报错,挑几个最常见的分享出来,你们如果遇到类似问题可以少走弯路。
第一个是npm install报ERR_OSSL_EVP_UNSUPPORTED。这个问题发生在Node.js 17+版本下安装老依赖时,Webpack 4等工具使用了OpenSSL 3.0已经移除的算法。解决办法有两个:升级依赖到兼容版本,或者临时用NODE_OPTIONS=--openssl-legacy-provider npm run dev绕过。前者是长久之计,后者只能应急。
第二个是跨域请求带不上Cookie。前面提到的credentials: true配置和Axios的withCredentials必须同时设置,缺一个都会导致浏览器丢弃Cookie。我在后端CORS配置正确但前端忘了加withCredentials时,排查了很久。
第三个是WebSocket连接频繁断开。关键原因是我在WebSocket握手时用了自定义的Sec-WebSocket-Protocol子协议头,但Nginx没有转发这个头,导致浏览器认为协议协商失败。解决办法是给Nginx的location加以下配置:
location /ws/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }下面整理成问题速查表,方便你们直接查阅:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| npm命令无法加载ps1脚本 | PowerShell执行策略限制 | 执行Set-ExecutionPolicy RemoteSigned |
| npm install报OpenSSL错误 | Node.js 17+与旧依赖冲突 | 升级依赖或加--openssl-legacy-provider |
| 跨域带不上Cookie | CORS配置或withCredentials缺失 | 两边同时开启credentials支持 |
| WebSocket秒断 | Nginx未转发Upgrade头 | 配置proxy_set_header Upgrade和Connection |
| 数据库连接数打满 | 多进程模式连接池过大 | 调低连接池上限,合理分配实例数 |
| 订单重复支付回调 | 回调接口未做幂等 | 用分布式锁或唯一约束保证幂等 |
| 实例到期未自动释放 | 定时任务未覆盖该实例 | 检查expire_at索引和任务调度日志 |
7. 项目迭代方向与个人踩坑总结
7.1 后续可以扩展的功能方向
这个算力商城目前已经把交易闭环跑通了,但距离一个成熟的算力平台还有很多可以扩展的地方。第一个方向是增加算力任务编排能力,比如用户下单后不仅拿到一台裸机,还能自动配置好Python环境、PyTorch框架、挂载数据集,这在应用层做起来就是交付模块里多加几个配置步骤。
第二个方向是账单与成本分析。用户一个月下来用了几百块钱算力,目前只能看订单流水,如果增加“按项目”“按时间段”“按实例类型”维度的账单报表,对团队用户会有很大吸引力。这部分的底层是钱包流水表的聚合查询,扩展起来并不难。
第三个方向是市场化的算力共享。大多数GPU在夜间利用率很低,而另外一个用户可能在夜间有紧急训练任务。如果把闲置算力挂到商城里做“夜间特惠场”,既是商品运营的一种玩法,也符合低碳计算的价值理念。搞一个“夜间折扣时段”的价格策略就能实现,核心逻辑就是计价模块里多算一个时段系数。
7.2 整个项目做下来,我最真实的几个体会
第一个体会是,商城系统的难点永远不在“增删改查”,而在于状态机、并发、幂等、一致性这些看不见的地方。订单状态流转稍微疏忽一点,就会出现“支付了但交付不了”“交付了但状态没更新”的情况。所以开发过程中一定要把所有状态变更点都集中到Service层统一管理,不要在前端或者Controller层随意改订单状态。
第二个体会是,数字商品和实体商品在交易系统设计上的差异很大。实体商品要考虑库存、物流、签收,数字商品要想的是交付、启停、计费周期。这个算力商城让我对“交付”这个词有了更深的理解——交付不是发货,而是一个服务从无到有的过程,它本身就可以做成一个可视化的产品功能。
第三个体会是,技术选型没有绝对的最好,只有适不适合当前业务阶段。Node.js + Vue3这套组合在个人开发和中小团队场景下非常顺手,开发效率高、生态完善、前后端语言统一,但在超大规模和超高并发场景下,确实需要考虑更复杂的技术方案。关键是你要清楚自己项目当前的核心矛盾是什么,然后选能解决这个矛盾的方案,而不是盲目跟风追新技术。
最后说一个运维上的小技巧:算力商城这类系统的后台定时任务(续费检查、到期提醒、实例释放)一定要有日志和监控面板,至少要做到每次任务执行后记录任务ID、扫描记录数、成功数和失败数。我之前就是因为定时任务静默失败,导致几个实例到期后一直没释放,白白多跑了好几天,费用账单出来才反应过来。后来我给所有定时任务加了异常告警,每次执行完把关键指标打到日志系统,再也没出现过这种“静默事故”。