简介:这份压缩包是一套基于Go语言后端与uniapp前端的商城系统完整源码,面向具备一定基础的Web开发者、电商系统学习者及毕业设计人员。项目采用前后端分离架构,覆盖商品管理、订单流程、用户体系等电商核心模块,可作为二次开发脚手架或课程项目参考。包内含384个文件,约19.39MB,主要类型包括184个vue页面文件、96个js脚本、36个go后端源文件、19个scss样式,以及json、xml、Dockerfile、yaml等配置与部署文件,结构规整,便于按前端、后端、配置分类查阅。目前已有105人学习下载。资源价值在于提供可直接运行的前后端代码与工程化配置,读者可据此理解Go语言接口设计、uniapp跨端组件封装及商城业务逻辑落地方式,也能借助Dockerfile与makefile快速搭建本地环境,节省从零搭建项目的时间。
1. 为什么是 Go + uni-app 商城系统在中小团队是保守选择
商城系统业务复杂度有限,真正的成本在两层:后端要扛住大促时短暂的峰值流量,前端要同时交付微信小程序、H5 和 App。Go 依靠 goroutine 和高并发能力,能在较小代价下搭出稳定的接口层;uni-app 则把 Vue 代码编译到多个端,省掉维护三套前端。这套组合看似“各取所需”,实际联调时最深的坑不在语法,而在支付回调、登录态同步和分页加载的边界处理。下面从一个可直接落地的商城系统出发,把整体架构、数据表、Go 核心接口、uni-app 前端调用和打包发布的关键点逐一拆开。适合正在做独立商城项目、想把代码从能跑提升到能上架的技术同学。
2. 商城系统的 Go + uni-app 整体架构与数据模型设计
2.1 从 zip 源码包看目录该如何分层
一个基于 Go 和 uni-app 的商城系统,前后端通常会拆成server和client两个顶层目录。拿到源码压缩包后,先看目录而不是直接跑命令,能快速判断它的模块边界和管理方式。我一般会这样组织:
mall-system ├── server # Go 后端服务 │ ├── cmd/api/main.go # 程序入口 │ ├── internal/ │ │ ├── handler/ # HTTP 层,负责参数绑定和响应输出 │ │ ├── service/ # 业务逻辑,事务边界放在这一层 │ │ ├── model/ # GORM 模型与数据库字段映射 │ │ └── repository/ # 数据访问,缓存和查询封装 │ ├── pkg/utils/ # 公共方法:JWT、支付签名、订单号生成 │ └── config/config.yaml # 连接信息、缓存策略、公众号配置 └── client # uni-app 前端工程 ├── pages/ # 首页、分类、购物车、我的等页面 ├── components/ # 商品卡片、弹出支付框、空状态 ├── store/ # Vuex / Pinia 状态管理 ├── static/ # 静态资源 └── manifest.json # uni-app 应用配置,打包时必须关注这个结构把 handler 和 service 分开,好处是当秒杀活动要改库存扣减逻辑时,不需要碰 HTTP 层。如果你拿到的源码包把main.go和路由、业务全塞在十几个文件里,后续加支付渠道会非常痛苦。internal目录是 Go 语言约定,外部包无法引用内部实现,适合做权限边界。
对应这张表,可以快速定位一个功能的数据落在哪张表:
| 业务模块 | 核心表 | 说明 |
|---|---|---|
| 用户 | users | 微信 openid 唯一,余额可冗余 |
| 商品 | products / product_skus | spu 与 sku 分离,库存按 sku 扣 |
| 购物车 | carts | 一个用户一行或多行,合并相同 sku |
| 订单 | orders / order_items | 主单存金额与状态,子单存快照 |
| 支付 | payment_logs | 记录支付流水,用于对账 |
商品表拆 spu 和 sku 是成熟模式:spu 是“海蓝色连衣裙”,sku 是“海蓝色/M码”,库存、价格、图片都应该挂在 sku 上,订单快照也要冗余 sku 的文字描述和图片,防止商品改版后历史订单显示异常。
2.2 关键表字段设计:别把钱存成 float
商城表数量通常在 30 张以上,但最值得关心的是用户表和订单表。下面这组建表语句是常见做法,刻意把金额字段设为 decimal,并加了版本号。
CREATE TABLE `users` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `openid` VARCHAR(64) NOT NULL DEFAULT '', `nickname` VARCHAR(64) NOT NULL DEFAULT '', `phone` VARCHAR(20) NOT NULL DEFAULT '', `balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `order_sn` VARCHAR(40) NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `version` INT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX `idx_user` (`user_id`), UNIQUE KEY `uk_order_sn` (`order_sn`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;balance使用 decimal 而不是 float,避免 0.1 + 0.2 出现精度问题;order_sn必须有唯一索引,因为退款和对账需要靠它定位原单;version字段用乐观锁,在支付回调和运营改价时可以减少错误更新。orders表里没有存完整的收货人快照,真实系统应该有receiver_name、receiver_phone、receiver_address三个字段,下单时从用户地址表复制进来。这样即使地址后续变更,历史订单仍然能发货。
2.3 接口约定:统一响应体和异常码
前后端联调时,最常见的浪费发生在错误码不统一。Go 后端返回{"code":0,"message":"ok","data":{}},uni-app 端在 request 封装里统一判断code而不是固执地依赖 HTTP 状态码。约定一组异常码能让前端逻辑简单很多:
| code 值 | 含义 | uni-app 端处理 |
|---|---|---|
| 0 | 成功 | 进入业务分支 |
| 10001 | 参数错误 | 提示 message |
| 10002 | 登录态失效 | 清除 token,跳转登录页 |
| 20001 | 库存不足 | 返回上一页并刷新购物车 |
响应结构不一定要强制所有接口都包一层data,但登录、支付这类关键接口必须统一。message是给用户看的文案,调试时也可以带debug字段,但上线前要删掉。Go 侧可以用中间件统一包装,否则每次 handler 里都要手写 c.JSON,一旦漏掉就会中断约定。
3. Go 后端商城核心接口实现(登录、商品、下单与支付)
3.1 小程序登录:用 code2session 换 openid
uni-app 在小程序端通过uni.login拿到临时 code,后端必须拿这个 code 去微信服务器换 openid 和 session_key。这个 code 只能用一次,有效期 5 分钟,所以不能保存在客户端。Go 端实现如下:
package main import ( "encoding/json" "fmt" "io" "net/http" ) type Code2SessionResp struct { OpenID string `json:"openid"` SessionKey string `json:"session_key"` UnionID string `json:"unionid"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } func code2session(code string) (openid string, sessionKey string, err error) { url := fmt.Sprintf( "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", "wxYourAppID", "yourAppSecret", code) resp, err := http.Get(url) if err != nil { return "", "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result Code2SessionResp if err = json.Unmarshal(body, &result); err != nil { return "", "", err } if result.ErrCode != 0 { return "", "", fmt.Errorf("wechat err: %d %s", result.ErrCode, result.ErrMsg) } return result.OpenID, result.SessionKey, nil }这里appid和secret必须保存在 Go 后端环境变量或配置文件中,不能出现在 uni-app 代码里,否则任何人反编译小程序都能拿到你的密钥。grant_type固定为authorization_code。拿到 openid 后,先查users表,不存在则创建新用户,最后签发自己的 token(JWT 或随机串)返回 uni-app。session_key 只用于解密手机号、运动步数等敏感数据,不要把它返回给前端。
3.2 商品列表:缓存防抖和可用的分页参数
商品列表是商城的读多写少接口,不能用一条 SQL 打天下。首先固定分页参数:page从 1 开始,pageSize限制在 20 以内。其次给列表加上 Redis 缓存,但缓存时间不能太长,否则后台改价不能准时生效。
func ListProducts(rdb *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) if page < 1 { page = 1 } if pageSize > 50 { pageSize = 50 } cacheKey := fmt.Sprintf("product_list:%d:%d", page, pageSize) // 命中缓存直接输出 JSON if cached, err := rdb.Get(c, cacheKey).Result(); err == nil { c.Header("Content-Type", "application/json") c.String(http.StatusOK, cached) return } var total int64 db.Model(&Product{}).Count(&total) var products []Product db.Where("status = ?", 1). Offset((page - 1) * pageSize). Limit(pageSize). Find(&products) payload, _ := json.Marshal(map[string]interface{}{ "code": 0, "message": "ok", "data": map[string]interface{}{"list": products, "total": total}, }) rdb.Set(c, cacheKey, payload, 30*time.Second) c.Header("Content-Type", "application/json") c.String(http.StatusOK, string(payload)) } }DefaultQuery里第二个参数是缺省值,可以抵抗 uni-app 端漏传参数的场景。缓存 key 里带页码和每页数量,避免不同分页互相覆盖。30 秒过期时间对应商品上下架和价格调整的容忍度,活动期间可以降到 5 秒。如果缓存被大量击穿,可以给单飞逻辑上加一个分布式锁,但商城项目通常先把缓存时间调短就够了。
3.3 下单事务:锁住 SKU 库存而不是先改后查
下单最容易出的问题是超卖。原因很简单:两个请求同时读到库存为 1,各自扣减,但数据库最终只剩 0。在 Go 里用事务加行锁是控制并发最直接的手段:
func CreateOrder(tx *gorm.DB, req CreateOrderReq) error { // 开启事务,确保库存扣减和订单创建一起成功或失败 err := tx.Transaction(func(tx *gorm.DB) error { for _, item := range req.Items { var sku ProductSku // SELECT * FROM product_skus WHERE id=? FOR UPDATE if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&sku, item.SkuID).Error; err != nil { return err } if sku.Stock < item.Num { return ErrInsufficientStock } // 使用 SQL 表达式扣减而不是先读再写 if err := tx.Model(&ProductSku{}). Where("id = ? AND stock >= ?", item.SkuID, item.Num). UpdateColumn("stock", gorm.Expr("stock - ?", item.Num)).Error; err != nil { return err } } order := Order{UserID: req.UserID, OrderSN: generateOrderSN(), Status: 0} if err := tx.Create(&order).Error; err != nil { return err } return tx.Create(&order.Items).Error }) return err }clause.Locking{Strength: "UPDATE"}会生成FOR UPDATE,在同一时刻只允许一个事务修改该 SKU 行。WHERE 条件里再限制stock >= item.Num,即使前面的行锁因为某种原因失效,这条 UPDATE 影响行数为 0 也能作为回滚信号。订单号生成要避免用自增 id,因为高并发下外部可能猜到订单数量,常见做法是时间戳 + 用户ID + 随机数拼接,再通过唯一索引兜底。
3.4 支付回调:验签、金额核对和幂等
支付回调是商城最难调试的接口之一。微信支付在 H5、小程序和 App 上请求参数不同,但回调格式基本一致。需要验签、解密资源、核对金额,然后用订单状态做幂等。
| 场景 | 回调前状态 | 回调后状态 |
|---|---|---|
| 正常支付 | 0 待支付 | 1 已支付 |
| 重复回调 | 1 已支付 | 继续保持 1 |
| 金额不符 | 0 待支付 | 不做状态更新,返回失败 |
处理回调时不能用“先查订单再更新”这种两步操作,而是把更新条件直接放进WHERE中:
res := db.Model(&Order{}). Where("order_sn = ? AND status = 0", payInfo.OutTradeNo). Update("status", 1)这样只有第一个回调能把状态从 0 改成 1,第二个回调更新 0 行,不会被重复入账。payment_logs表还要记录transaction_id,它来自微信支付返回的交易号,后续退款要用到。
4. uni-app 前端对接接口与商城页面实现(登录、商品、订单)
4.1 request 封装:统一鉴权和错误处理
uni-app 的接口请求统一封装在utils/request.js中。后端返回的code不是 HTTP 状态码,因此前端必须建立一个常量和响应处理,避免每个页面重复写 toast。
// utils/request.js const BASE_URL = 'https://api.example.com' export default function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'POST', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': uni.getStorageSync('token') ? `Bearer ${uni.getStorageSync('token')}` : '' }, success: (res) => { const body = res.data if (body.code === 0) { resolve(body.data) } else if (body.code === 10002) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) } else { uni.showToast({ title: body.message || '服务异常', icon: 'none' }) } }, fail: (err) => { uni.showToast({ title: '网络错误', icon: 'none' }) reject(err) } }) }) }BASE_URL在开发时可以指向 Go 后端的局域网地址,打包前必须改为 HTTPS 域名;微信小程序对未配置的合法域名直接拦截,这在联调期会浪费很多时间。Authorization头里带的 token 应该在下面登录流程中写入uni.setStorageSync。注意uni.request是异步的,封装成 Promise 后页面可以用 async/await,避免嵌套回调。
4.2 首页商品列表:触底加载与下拉刷新
商城首页一定会有分页。uni-app 利用页面的生命周期onReachBottom和onPullDownRefresh实现加载和刷新,比自己在滚动事件里写判断更稳。
import request from '@/utils/request.js' export default { data() { return { list: [], page: 1, pageSize: 10, finished: false, loading: false } }, onLoad() { this.getList() }, onPullDownRefresh() { this.page = 1 this.list = [] this.finished = false this.getList().finally(() => uni.stopPullDownRefresh()) }, onReachBottom() { this.getList() }, methods: { async getList() { if (this.loading || this.finished) return this.loading = true try { const res = await request({ url: '/api/v1/products', method: 'GET', data: { page: this.page, pageSize: this.pageSize } }) this.list = this.list.concat(res.list) this.page += 1 this.finished = res.list.length < this.pageSize } finally { this.loading = false } } } }finished置为 true 后,滚动到底部不再发请求,这是很多新手会忽略的点。loading防止触底事件连续触发发出重复请求。pageSize必须与 Go 端一致,后端如果限制最大 50,前端就传 10 或 20。下拉刷新要调用uni.stopPullDownRefresh,否则动画一直转。
4.3 登录流程与拉起微信支付
小程序登录需要两步:先用uni.login拿到 code,再调用后端换取 token。支付时不能把商户证书放在前端,后端应该先预下单并返回uni.requestPayment所需的参数。
// 登录页 uni.login({ provider: 'weixin', success: async (loginRes) => { try { const data = await request({ url: '/api/v1/login', method: 'POST', data: { code: loginRes.code } }) uni.setStorageSync('token', data.token) uni.setStorageSync('userInfo', data.userInfo) uni.switchTab({ url: '/pages/index/index' }) } catch (error) { console.error('login error', error) } } }) // 订单支付 async function payOrder(orderSn) { try { const payParams = await request({ url: '/api/v1/pay', method: 'POST', data: { orderSn } }) uni.requestPayment({ provider: 'wxpay', // App 端: wxpay / alipay timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, // 这里 package 是后端返回的参数名 signType: 'RSA', paySign: payParams.paySign, success: () => uni.showToast({ title: '支付成功' }), fail: (err) => { if (err.errMsg !== 'requestPayment:fail cancel') { uni.showToast({ title: '支付失败', icon: 'none' }) } } }) } catch (error) { console.error('pay error', error) } }不同运行端拉支付的 provider 不同,整理成一张表方便切换:
| 运行端 | provider 值 | 后端需要准备 |
|---|---|---|
| 微信小程序 | wxpay | 商户号、证书、支付回调地址 |
| Android/iOS App | wxpay / alipay | 开放平台应用、对应 SDK 参数 |
| H5 公众号 | wxpay | JSAPI 支付参数和公众号授权域名 |
支付回调的package在微信支付文档里叫package,但在某些 uni-app 版本中也写packages。最好是以后端返回的 key 为准,不要硬编码。支付取消的错误码也建议过滤,避免用户点取消时看到 “支付失败” 的红色提示。
5. 商城系统打包发布与 uni-app 实战排错(Android 上架 / H5 公众号定位 / 首屏加载页)
5.1 manifest 配置与上架应用市场的必调项
uni-app 工程打包前要重点看manifest.json。云打包 Android 时,appid是 DCloud 平台生成的,不能随便改;包名要具备唯一性,并且和签名证书的包名一致。Android 应用市场普遍要求上传 aab 格式,而 uni-app 云打包需要勾选“Android 上架专用 aab”选项。权限请求尽量按需声明,比如商城定位功能只需要精准位置和网络权限,不要贪多复制 statusbar 权限。
{ "appid": "__UNI__XXXX", "name": "商城系统", "versionName": "1.0.0", "versionCode": 100, "app-plus": { "distribute": { "android": { "packagename": "com.example.mall", "permissions": [ "ACCESS_FINE_LOCATION", "ACCESS_NETWORK_STATE" ] } } } }5.2 H5 嵌入微信公众号时的定位与分享
商城 H5 在微信里打开时,uni.getLocation不一定直接可用。先在页面注入微信 JS-SDK,再由 Go 后端通过wx.config返回签名配置,签名算法需要当前页面的 URL。常见错误是签名 URL 里带了#/pages/...等 hash,导致invalid signature。配置完成后调用uni.requireNativePlugin或者直接使用uni.getLocation({ type: 'gcj02' }),定位成功率会明显提高。
5.3 把刚进入的加载页改成自定义引导页
小程序冷启动时,如果不做首屏加载逻辑,用户看到的是白屏或默认加载图标。常见做法是在pages.json中把第一项改为一个自定义 loading 页,在其中完成版本检查、拉取用户信息,再uni.redirectTo到商城首页。
{ "pages": [ { "path": "pages/loading/loading", "style": { "navigationStyle": "custom" } }, { "path": "pages/index/index", "style": { "enablePullDownRefresh": true } } ] }这样首屏就成为可编程的引导页,而不是等待白屏。
本文还有配套的精品资源,点击获取