简介:本资源是一套基于Spring Boot + Vue3 + UniApp技术栈开发的完整点餐小程序源码,面向Java后端、Vue前端及跨端小程序开发者,解决多端统一交付与高复用业务系统快速搭建问题。压缩包共499个文件,含78个Java类(如SysGoodsController、UserOrderServiceImpl等核心业务逻辑)、127个Vue组件(实现商品展示、订单管理、用户交互等界面)、58个JS/40个TS脚本(支撑UniApp多端适配与状态管理)、40个PNG图标资源及配置类YML、JSON、XML文件,整体大小19.16MB。已有832人学习下载。资源结构遵循标准工程规范,后端采用Spring Boot RESTful API提供稳定服务,前端通过Vue3 Composition API组织模块化组件,UniApp层封装平台差异并支持一键编译至微信小程序、H5及App,附带完整数据库交互、用户认证、订单流程与商品评论等真实业务闭环代码,可直接用于教学实践、毕设开发或中小餐饮项目快速落地。 做点餐小程序这个项目,我前后花了大概三周时间把一整套流程完整跑通,从后端接口设计到前端页面渲染,再到兼容安卓和iOS的H5端、小程序端,最终成功上线。过程中踩了不少坑,尤其是Spring Boot版本选择、Vue3组合式API在小程序端的表现,以及uniapp上架安卓应用市场时的各种配置问题。这篇就把整个项目的完整落地过程写出来,从技术选型、表结构设计、核心接口实现,到前端状态管理、跨端适配、上架审核,每一步都带有实际的代码和踩坑记录,希望对正在做同类项目或准备做毕设的同学有参考价值。
1. 为什么是Spring Boot+Vue3+Uniapp这一套组合
先聊选型。这个点餐小程序的定位是面向中小餐饮商家的轻量级解决方案,需要覆盖用户点餐、商家管理、后台运营三个端口,分别对应小程序端、商家H5管理端、PC管理后台。选型时考虑过几套方案,最终确定Spring Boot+Vue3+Uniapp,原因很直接。
后端用Spring Boot,理由简单粗暴——生态成熟、上手快、招人容易,而且对于点餐这种以CRUD为主、带一点订单状态流转的业务场景,Spring Boot的约定优于配置能让开发效率翻倍。版本这里有个重要提醒:千万别无脑上最新版。我之前用过Spring Boot 3.x,结果遇到很多第三方库还没适配Jakarta命名空间的问题,比如一些老的MyBatis插件、代码生成器直接跑不起来,光是迁移javax到jakarta就折腾了半天。这个项目里用的是Spring Boot 2.7.x,稳定、兼容性强,大部分轮子都是现成的。
前端管理端选Vue3,用户端用uniapp。Vue3的组合式API(Composition API)在管理端这种多模块复杂页面里优势非常明显,特别是做点餐菜品管理、订单列表这种需要大量状态共享的场景。而uniapp能让一套代码同时编译到微信小程序、支付宝小程序、H5和App,对中小商家来说,多端覆盖意味着多一个流量入口,这个诱惑力很大。
选uniapp还有一个关键理由:它的条件编译机制可以很好地处理各平台的差异。比如微信小程序里要使用微信支付,支付宝小程序里要使用支付宝支付,这些通过#ifdef条件编译就能优雅地实现平台差异化逻辑,而不需要维护多套代码。
不过也要说句公道话,uniapp在复杂交互场景下偶尔会有一些性能问题,比如长列表渲染、复杂动画等,但这个项目里点餐流程的交互并不算复杂,商品列表用mescroll-uni做分页加载,性能完全够用。如果做的是那种带即时聊天、实时音视频的大型应用,可能还是要考虑原生开发。
整套技术栈下来,个人的体会是:Spring Boot负责业务逻辑和数据持久化,Vue3负责管理端的复杂交互,uniapp负责用户端的跨端覆盖,各司其职,配合默契。
2. 后端核心模块与表结构设计:先从下单流程倒推数据模型
后端设计我是从“用户完整下单流程”倒推的,这样表结构更贴近实际业务。整个点餐流程是这样的:用户进入小程序选择门店,然后浏览菜品分类、加购物车,提交订单,商家接单后开始制作,用户到店自取或外卖配送,最后完成订单。围绕这条链路,核心表有8张:门店表、菜品分类表、菜品表、购物车表、订单表、订单明细表、用户表、商家表。
门店表字段不多,但有个细节值得注意:经纬度字段建议用DECIMAL(10, 6)类型存储,而不是直接用浮点型,避免精度丢失。别小看这个细节,后续如果做“附近门店”功能,经纬度有精度问题会导致距离计算偏差很大。
菜品表是整个菜单模块的核心:
CREATE TABLE `dish` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `store_id` bigint NOT NULL COMMENT '所属门店ID', `category_id` bigint NOT NULL COMMENT '分类ID', `name` varchar(64) NOT NULL COMMENT '菜品名称', `price` decimal(10,2) NOT NULL COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价,用于展示折扣', `image` varchar(255) DEFAULT NULL COMMENT '菜品图片', `description` varchar(500) DEFAULT NULL COMMENT '描述', `stock` int DEFAULT '0' COMMENT '库存,-1表示不限量', `status` tinyint DEFAULT '1' COMMENT '状态:1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_store_category` (`store_id`, `category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';联合索引idx_store_category很关键。点餐场景下,用户每次打开菜单,小程序都会请求“某个门店下的某个分类的菜品列表”,如果没有这个联合索引,数据量上来之后这个查询会非常慢。我在这个项目里加了这个索引后,一万多条菜品数据查询耗时从原来的300多毫秒降到了20毫秒左右,效果立竿见影。
订单表是另一个需要重点设计的表。外卖和到店自取要区分,订单状态要清晰,支付信息要完整。我的订单表设计包括订单号、用户ID、门店ID、订单类型(1自取 2外卖)、订单状态、支付状态、支付方式、订单金额、实付金额、收货地址、备注、下单时间、支付时间、完成时间等字段。
订单号生成有个坑要特别注意:不要用数据库自增ID直接当订单号,很容易被竞争对手推算出单量。我用了“门店ID后四位+时间戳+随机四位”的方式生成业务订单号,比如1001202312111435001234。这样既保证唯一性,又不会泄露真实单量。
订单状态流转是点餐系统的核心,我用了状态机模式:
| 状态 | 含义 | 可流转到的状态 |
|---|---|---|
| 0 | 待支付 | 1、6 |
| 1 | 待接单 | 2、7 |
| 2 | 制作中 | 3 |
| 3 | 待取餐/待配送 | 4 |
| 4 | 完成 | - |
| 5 | 已评价 | - |
| 6 | 已取消 | - |
| 7 | 已退款 | - |
这个状态机的设计原则是“每个状态只允许它该有的流转”,比如待支付订单只能取消或支付成功变待接单,不能直接跳成已完成。我在后端有一个OrderStateMachine类专门管理状态的合法性校验,避免出现“订单都没支付就直接完成”的脏数据问题。
订单明细表也有讲究,不仅要记录菜品ID、名称、价格、数量,还要把当时下单的菜品快照存进去,包括菜品的图片、规格、口味。为什么要存快照?因为菜品信息是可变的,今天卖18块、明天搞活动卖15块,但已经下单的订单在用户端应该显示的是下单时的价格,不是现在改过的价格。很多新手容易忽略这一点,导致用户看到的历史订单金额和当前菜品价格对不上。
这里建议用MyBatis-Plus作为持久层框架,真的能提升效率。内置的LambdaQueryWrapper写条件查询非常方便,比如查某门店的菜品列表:
Page<Dish> page = dishMapper.selectPage(new Page<>(pageNum, pageSize), new LambdaQueryWrapper<Dish>() .eq(Dish::getStoreId, storeId) .eq(Dish::getStatus, 1) .eq(Dish::getCategoryId, categoryId) .orderByDesc(Dish::getSort));代码简洁、类型安全,而且不用手写XML映射。不过要注意,复杂SQL还是建议用@Select注解或XML,灵活度更高,比如统计销量排行这类要多表联查的报表SQL。
3. 商品分类与菜单列表接口:一次请求还是多次请求
用户进店后第一个看到的就是菜单。这里有个设计决策:菜单页的“左侧分类、右侧菜品”是请求一次接口返回全量数据,还是左侧分类请求一次、右侧菜品按分类再请求一次?
我做的时候选择了后者,也就是分类列表和菜品列表分开两个接口。原因有二:一是小程序端首页加载速度更快,首屏先拿到分类列表渲染左侧导航,用户点击某个分类时再拉取对应菜品,首屏数据量缩小了80%以上;二是用户体验更流畅,用户可以快速浏览各个分类,而不需要等所有菜品一次性加载完。
分类接口很简单:
@GetMapping("/category/list") public Result<List<CategoryVO>> list(@RequestParam Long storeId) { List<CategoryVO> list = categoryService.listByStoreId(storeId); return Result.success(list); }菜品列表接口带分页和缓存:
@GetMapping("/dish/list") public Result<PageResult<DishVO>> list(@RequestParam Long storeId, @RequestParam Long categoryId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { // 优先查缓存,未命中再查数据库 String cacheKey = "dish:list:" + storeId + ":" + categoryId + ":" + pageNum; PageResult<DishVO> result = redisService.get(cacheKey); if (result == null) { result = dishService.pageByCategory(storeId, categoryId, pageNum, pageSize); redisService.set(cacheKey, result, Duration.ofMinutes(5)); } return Result.success(result); }菜品数据属于低频变更数据,加了Redis缓存后,菜单加载速度从平均150ms降到了30ms左右。这里要注意缓存刷新时机:菜品上下架、价格调整时必须主动删除对应分类的缓存,否则用户端看到的还是旧数据。我在菜品的增删改方法里用了@CacheEvict注解,每次操作完自动清除对应门店、对应分类下的菜品缓存。
另一个容易被忽略的细节:菜品列表的排序规则。我建议按“自定义排序权重+销量”降序排列,权重值越小越靠前,这样商家可以把招牌菜、推荐菜排在最前面。代码里用的orderByDesc(Dish::getSort)再orderByDesc(Dish::getSales),用户看到的第一屏是商家想推荐的东西,转化率会高不少。
4. 购物车设计与实现:前端缓存还是后端存储
购物车是点餐流程里用户交互最多的模块。这里先抛一个结论:购物车数据放在前端本地缓存(uniapp的uni.setStorageSync)就足够了,不需要后端建表维护。
为什么?因为购物车本质上是一个临时容器,用户加了菜没下单,这个数据对商家毫无意义;用户下单后,购物车数据会以订单明细的形式落到后端,又有据可查。很多初学者喜欢把购物车也做成后端接口,但这样不仅增加了开发量,还会带来一个问题:用户频繁加菜、减菜,每次都要请求后端,服务器压力大,用户体验还不好。本地缓存的方案可以做到秒开,用户操作起来非常跟手。
前端我用Vue3的reactive配合computed管理购物车状态。购物车的核心数据结构是Map,key是菜品ID,value是菜品信息和数量,因为Map的增删改查都是O(1)复杂度,特别适合这种频繁操作的场景。
import { reactive, computed } from 'vue' const cart = reactive(new Map()) // 添加菜品 const addToCart = (dish) => { if (cart.has(dish.id)) { cart.get(dish.id).count++ } else { cart.set(dish.id, { ...dish, count: 1 }) } uni.setStorageSync('cart', JSON.stringify(Array.from(cart.entries()))) } // 计算总价 const totalPrice = computed(() => { let total = 0 cart.forEach(item => { total += item.price * item.count }) return total.toFixed(2) }) // 计算总数量 const totalCount = computed(() => { let count = 0 cart.forEach(item => { count += item.count }) return count })computed是Vue3里特别好用的API,它会对依赖的数据进行缓存,当cart变化时才重新计算总价和总数,比Vue2的computed写法更直观。这里有个小细节:每次addToCart后都要同步到uni.setStorageSync,这样即使用户退出小程序再进来,购物车数据也不会丢,体验会好很多。
有个购物车经典问题要提醒:用户在小程序端加入购物车,然后去PC端管理后台加菜或改价,两端的数据是不同步的。但如果购物车设计成后端存储,又会遇到“游客未登录也能加购”的矛盾。所以我才建议小程序端本地缓存购物车,等真正提交订单时再让用户登录(微信授权手机号或用openid直接创建账号),这样既保证了体验又简化了架构。
当然,如果做的是那种需要多端实时同步购物车的SAAS产品,那就要认真的设计后端购物车表了,加一个member_id字段关联用户,前端每次操作增删改后调后端接口同步。这个项目因为不需要多端同步,所以不增加这个复杂度。
5. 下单与库存扣减:预扣还是支付后扣
下单流程看似简单:用户提交购物车数据,后端生成订单,调用微信支付,同步通知商家。但里面藏着一个关键决策——库存扣减时机,这个项目我踩过不少坑。
先说说三种技术方案:
第一种是提交订单时直接扣库存,支付失败再返还。这种方式最直观,但问题在于:如果一个用户提交了订单但迟迟不支付,库存一直被占用,其他用户就买不到了,容易导致“僵尸订单饿死真实订单”的情况。
第二种是支付成功后才扣库存。这种方案相对安全,但存在超卖风险——两个用户同时提交了相同菜品的订单,系统在支付回调里扣库存时发现库存不够了,就会出现退单或体验不佳的问题。
第三种是提交订单时先冻结库存(即预扣),支付超时未支付再释放库存。这是电商系统里比较标准的做法,既能防止超卖,又能通过超时释放机制避免库存长期被占用。
我在这个项目里用的是“提交订单预扣库存 + 定时任务释放超时订单”的组合方案。用户在提交订单时,后端会直接扣除菜品库存,同时开启一个30分钟的倒计时,如果30分钟内未完成支付,定时任务会自动取消订单,将已扣的库存回补库存。
预扣库存的代码实现里有个关键技术点——数据库乐观锁,防止并发超卖:
@Update("UPDATE dish SET stock = stock - #{count}, sales = sales + #{count} " + "WHERE id = #{dishId} AND stock >= #{count}") int deductStock(@Param("dishId") Long dishId, @Param("count") Integer count);SQL里加上了stock >= #{count}条件,哪怕100个用户同时下单同一种菜品,数据库层面也会保证只有库存足够的那几个请求能成功扣减库存,其余请求要么重试要么友好提示“库存不足”。这个方法利用了数据库行锁的原子性,比在Java代码里加Synchronized或者使用分布式锁简单得多,也可靠得多。
订单超时未支付自动取消,我用的是Spring Boot的定时任务:
@Scheduled(fixedDelay = 60000) public void autoCancelExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredOrders(30); for (Order order : expiredOrders) { orderService.cancelOrder(order.getOrderNo()); // 回补库存逻辑 List<OrderDetail> details = orderDetailMapper.selectByOrderNo(order.getOrderNo()); for (OrderDetail detail : details) { dishMapper.addStock(detail.getDishId(), detail.getQuantity()); } } }定时任务每隔一分钟扫描一次,把超过30分钟未支付的订单自动取消并回补库存。这里有个小优化:扫描时只查询状态为待支付且订单时间早于30分钟前的订单,不要全表扫描,否则数据量大了之后定时任务会越跑越慢。
分布式环境下的定时任务有个要注意的点:如果未来系统扩展成多实例部署,这个@Scheduled会在每个实例上都执行一次,导致重复取消和重复回补库存。解决方式是用XXL-Job统一调度,或者至少给取消订单的方法加一个幂等判断,比如先查一次订单状态,只有待支付状态的订单才执行取消,我之前还用redis分布式锁控制过,以免多个实例同时处理同一笔订单。
6. 微信小程序端适配:从顶部导航栏到自定义页面分享
uniapp一套代码跑多端,理想很丰满,但真正落地时微信小程序端还是有一堆细节要折腾。这里记录几个我实际遇到并解决的高频问题。
6.1 顶部导航栏高度的适配问题
微信小程序和H5的顶部导航栏高度不一样,安卓和iPhone也不一样,这导致页面布局经常会出现“顶部被刘海遮挡”或者“按钮和状态栏重叠”的问题。
解决方案是动态获取状态栏高度和导航栏高度,然后给页面顶部留出安全的padding:
// 状态栏和导航栏高度计算 const getSystemInfo = () => { const systemInfo = uni.getSystemInfoSync() const statusBarHeight = systemInfo.statusBarHeight // 状态栏高度 const menuBtn = uni.getMenuButtonBoundingClientRect() // 胶囊按钮位置 const navBarHeight = (menuBtn.top - statusBarHeight) * 2 + menuBtn.height return { statusBarHeight, navBarHeight } }这段代码的原理是:微信小程序右上角胶囊按钮的高度和位置是固定的,通过getMenuButtonBoundingClientRect()获取胶囊按钮的位置和高度,就能反推出自定义导航栏应该在什么位置。这个方案在安卓和iOS上实测都稳定,强烈建议在pages.json里统一配置"navigationStyle": "custom"自定义导航栏,视觉上可以和页面融为一体,用户体验更好。
6.2 自定义分享好友的坑
点餐系统里“分享好友一起点餐”是个很常见的需求。用uniapp自带的onShareAppMessage钩子可以实现分享:
onShareAppMessage() { return { title: `【${this.storeName}】邀请你来点餐啦`, path: `/pages/index/index?storeId=${this.storeId}&inviter=${this.inviterId}`, imageUrl: this.shareImage } }这里有个很重要的点:如果你想每次分享时都动态生成不同的分享文案或小程序码图片,必须在onShareAppMessage里返回一个Promise,而不是直接return对象,因为微信小程序的分享回调有异步限制。如果直接掉接口获取自定义图片后再返回,可能会拿不到想要的分享卡片效果。
小程序码的生成也是一个重点。美团这种大厂的做法是货到付款,我们当然做不到那种规模,但可以用微信的getUnlimitedQRCode接口去生成小程序码,然后通过canvas把店铺logo、菜品图片、价格等信息画上去,最后uni.canvasToTempFilePath导出图片分享给好友。这里要注意canvas在小程序和H5端的API差异比较大,建议把这块逻辑封装成独立工具类,用条件编译分别处理。
6.3 曼奈斯特配置:manifest.json的常见问题
uniapp的manifest.json是连接各端的桥梁,很多初学同学在这儿踩过坑。微信小程序端配置注意几个字段:
{ "mp-weixin": { "appid": "你的小程序AppID", "setting": { "urlCheck": false, "es6": true, "postcss": true, "minified": true }, "usingComponents": true, "permission": { "scope.userLocation": { "desc": "用于获取用户位置信息以推荐附近门店" } } } }urlCheck字段在开发阶段一定要设置为false,否则每次请求后端接口都会提示“域名不合法”。但上线前记得改成true,或者直接去微信公众平台配置合法域名。我见过不少同学开发时模拟器里接口通,真机上一片空白,就是这个问题。
另外微信小程序对网络请求的合法域名有严格限制,如果后端接口跑在http://localhost:8080或者http://192.168.1.100:8080,真机调试是永远请求不通的。建议开发阶段用内网穿透工具把接口映射成HTTPS域名,或者直接使用局域网IP配合“不校验合法域名”的调试模式。
6.4 分包优化与首屏加载
微信小程序有2MB的主包大小限制,但对于带图片、带视频的点餐应用,随便加几张商品图就超了。解决方案是使用分包加载:把商品详情页、优惠券页面、订单列表页这类不是首屏必须的页面放进分包里,主包只保留首页、分类页和购物车页。
uniapp里的分包配置在pages.json里:
{ "pages": [ "pages/index/index", "pages/category/category", "pages/cart/cart" ], "subPackages": [ { "root": "pages/order", "pages": [ "order-confirm/order-confirm", "order-list/order-list", "order-detail/order-detail" ] }, { "root": "pages/mine", "pages": [ "mine/mine", "coupon/coupon", "address/address" ] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["pages/order"] } } }这样配置后,用户首次进入首页时不会加载订单分包的代码,等到真正点单确认时才去加载,首屏加载时间能减少40%左右。preloadRule的意思是用户进入首页后就预下载订单分包,等到用户下单确认页,分包代码已经提前缓存好了,进入时几乎没有白屏时间。
6.5 地图组件的选型问题
点餐系统里经常需要展示门店位置,热词里有人问“微信小程序可以使用天地图地图组件吗”。实测下来不太推荐,因为微信小程序的地图组件map官方支持的主要是腾讯位置服务和微信自己的地图服务,调用第三方地图服务(天地图、高德、百度)都需要通过web-view加载网页,体验和原生地图组件差很多。建议直接用微信的原生map组件,配合markers传门店坐标,再通过wx.chooseLocation选择收货地址,这是最省力也最稳定的方案。
7. Vue3管理后台的实践:defineProps和defineEmits的正确打开方式
管理后台用的Vue3+Element Plus,这里聊聊Vue3组合式API在两个高频场景上的使用心得。网上关于defineProps和defineEmits的教程很多,但很多都是简单示例,真正用到业务里的细节坑没有说透。
菜品编辑弹窗是一个很典型的“父组件传数据给子组件、子组件提交后通知父组件刷新”的场景:
父组件:菜品列表页
<template> <el-dialog v-model="dialogVisible" title="编辑菜品"> <DishForm v-if="dialogVisible" :dish="currentDish" @submit="handleSave" @cancel="dialogVisible = false" /> </el-dialog> </template> <script setup> import { ref } from 'vue' const dialogVisible = ref(false) const currentDish = ref(null) const handleSave = (formData) => { // 调用保存接口 saveDish(formData).then(() => { dialogVisible.value = false loadDishList() // 刷新列表 }) } </script>子组件:菜品表单
<script setup> const props = defineProps({ dish: { type: Object, default: () => null } }) const emit = defineEmits(['submit', 'cancel']) // 表单数据 const form = reactive({ name: '', price: 0, categoryId: null, image: '', description: '', stock: 0 }) // 监听父组件传入的dish变化,回填表单 watch(() => props.dish, (val) => { if (val) { Object.assign(form, val) } }, { immediate: true }) const handleSubmit = () => { emit('submit', { ...form }) } const handleCancel = () => { emit('cancel') } </script>这里有三个容易踩的坑:
第一个是v-if="dialogVisible"。如果不加这个条件,子组件在父组件还没拿到数据时就会被渲染,watch的immediate: true虽然能拿到null,但后续如果父组件的currentDish一直是同一个对象引用,watch可能不会触发更新,导致表单里显示的还是上一个菜品的数据。用v-if保证每次打开弹窗都是全新渲染,数据自然刷新。
第二个是Object.assign(form, val)这个回填方式。如果form和val的字段名不一致,比如后端返回的是category_id而下拉框绑定的是categoryId,直接Object.assign会把错误的字段塞进表单。建议在回填时使用form.name = val.name; form.categoryId = val.categoryId这种手动赋值的方式,虽然多写几行,但不容易出脏数据。
第三个是defineEmits的生命周期。子组件挂载前就会执行,所以在onMounted里监听emit是不行的,必须在setup顶层监听。上面的代码里直接在模板事件中调用emit('submit'),就不存在这个问题。
computed也是Vue3里被严重低估的一个API。在菜品管理后台,我经常需要根据搜索条件动态过滤菜品列表:
const searchText = ref('') const categoryFilter = ref('all') const statusFilter = ref('all') const filteredDishList = computed(() => { return dishList.value.filter(dish => { const matchName = dish.name.includes(searchText.value) const matchCategory = categoryFilter.value === 'all' || dish.categoryId === categoryFilter.value const matchStatus = statusFilter.value === 'all' || dish.status === Number(statusFilter.value) return matchName && matchCategory && matchStatus }) })用computed的好处是它基于响应式依赖自动缓存结果,当ssearchText、categoryFilter、statusFilter变化时才重新计算,不会像watch+methods那样每次渲染都执行一遍过滤逻辑,性能好不少。
8. 上架与部署:从开发环境到安卓应用商店的完整流程
这个项目的部署上线过程,让我对uniapp上架安卓应用市场有了完整的认知。这里面涉及的坑,远超开发阶段遇到的所有问题总和。
8.1 应用签名与证书的配置
如果要把uniapp打包成APK上架安卓应用市场,必须先生成签名证书。Android应用签名是应用的身份标识,没有签名的APK无法安装到手机上。我用的命令是:
keytool -genkeypair -v -keystore myapp.keystore -alias myapp -keyalg RSA -keysize 2048 -validity 10000参数解释:
-genkeypair:生成密钥对-keystore:证书文件名,这里生成的是myapp.keystore-alias:别名,代码里引用时要用-keyalg RSA -keysize 2048:使用RSA算法,密钥长度2048位-validity 10000:有效期10000天
执行后会提示输入密码、姓名、组织等信息,这些信息务必保存好,特别是密钥库密码和别名密码。上架后如果证书丢了,应用就无法更新了,只能换包名重新上架,之前积累的用户和评分都会归零,这是血淋淋的教训。
在HBuilderX里打包时,在“发行-原生App-云打包”界面选择“使用云端证书”或“使用本地证书”,把本地证书路径、别名、密码填上即可。
8.2 软件著作权申请
热词里有人问“uniapp上架如何申请软著”,这个确实绕不开。国内安卓应用市场(华为、小米、OPPO、vivo、应用宝)都要求提供软件著作权证书。我当时申请的是“点餐小程序管理系统”的软著,提交到中国版权保护中心,周期大概30-40个工作日,可以加急。
软著申请材料的核心是操作说明书和源代码文档:
- 操作说明书:每个页面截图加上文字说明,一般20-30页就够了
- 源代码文档:前后端代码的主要部分,每页50行,总共60页,前后各30页。这里建议把核心代码(Controller、Service、Mapper、前端页面)整理成一个文档,去掉空行注释,保持代码可读性即可,不需要提交全部代码。
8.3 各应用市场的审核差异
安卓应用市场特别多,审核标准也不尽相同。华为、小米对隐私政策要求非常严格,必须在小程序或App里明确展示隐私政策链接,否则会被拒绝。OPPO、vivo对应用内“用户协议”是否涵盖账号注销功能、用户信息删除功能有明确要求。应用宝对App的targetSdkVersion有版本要求,太低的会被直接拒绝。
我踩过的一个坑是:在manifest.json里配置了"minSdkVersion": 21,但某些市场的设备系统版本较低,导致安装后无法打开。后来统一调整为minSdkVersion: 21、targetSdkVersion: 30,既保证了兼容性又满足了市场要求。
另外一个要提前准备的:应用商店截图。各市场对截图尺寸要求不同,但一般都要提供5张竖屏截图(尺寸在1080x1920左右)和1张横屏宣传图,建议提前用模拟器在不同分辨率下截图,避免临时抓瞎。
8.4 后端上线部署的Spring Boot注意事项
后端的部署相对简单,我用的是阿里云轻量应用服务器+宝塔面板+Nginx反向代理。打包命令:
mvn clean package -DskipTests生成的target/dish-server.jar放到服务器上,写一个启动脚本:
nohup java -jar dish-server.jar \ --spring.profiles.active=prod \ --server.port=8080 \ --spring.datasource.url=jdbc:mysql://localhost:3306/dish?useUnicode=true&characterEncoding=utf8mb4 \ --spring.datasource.username=root \ --spring.datasource.password=xxxx \ > logs/dish.log 2>&1 &nohup和&让Java进程在后台持续运行,即使SSH断开也不会终止。--spring.profiles.active=prod指定生产环境配置,在application-prod.yml里配置生产环境的数据库连接、Redis地址等。
这里有个经验之谈:生产环境的数据库密码不要写在配置文件里直接提交到Git仓库,要么用环境变量注入,要么用配置中心管理。我用的是启动命令里通过--spring.datasource.password=${DB_PASSWORD}方式引用环境变量,这样即使配置文件泄露了也不会直接暴露数据库密码。
Nginx反向代理的配置:
server { listen 443 ssl; server_name api.yourdomain.com; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }微信小程序上线时必须使用HTTPS协议且端口是443,这是硬性条件。开发阶段可以不校验域名直接请求HTTP接口,但生产环境必须要HTTPS证书,否则小程序无法正常请求后端接口。我就是用Nginx配置好了HTTPS反向代理,才顺利通过微信小程序的审核。
8.5 微信小程序审核的常见驳回原因
微信小程序审核比安卓应用市场还要严格,常见驳回原因有几个方向:涉及餐饮类目需要提供食品经营许可证;用户隐私保护指引要明确表示收集了哪些用户信息、具体用途;如果小程序里有用户上传图片、评价等UGC功能,需要增加内容审核机制;诱导分享或者强制关注公众号等推广陷阱的,也会被驳回。
我遇到过一次驳回是因为“分享有礼”活动被判定为利益诱导分享,被迫把分享加积分功能改成普通分享,才过了审核。建议在功能设计阶段就避开这些红线。
9. 从开发到上线的一个补充:接口安全与防刷
最后补充一点容易被忽略的东西——接口安全。
点餐小程序对外开放了菜品列表、下单、支付回调等接口,如果不对接口做基础防护,很容易被恶意刷单或爬取数据。我做了几个简单有效的防护措施:
第一,小程序端接口统一要求请求头携带Authorization字段,值为用户在登录后获取的token。生成token的方式是用JWT(JSON Web Token),单点登录和鉴权都很方便,而且无需在服务端存session,天然适合无状态API设计。
String token = Jwts.builder() .setSubject(userId.toString()) .claim("openid", openid) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();第二,配置拦截器统一校验token:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行不需要登录的接口 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/category/list") || uri.contains("/dish/list")) { return true; } // 校验token String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BusinessException(401, "未登录"); } try { Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); request.setAttribute("userId", Long.parseLong(claims.getSubject())); return true; } catch (Exception e) { throw new BusinessException(401, "登录已过期"); } } }第三,下单接口增加频率限制,同一用户30秒内最多提交1次订单,防止用户快速重复提交同一订单:
String lockKey = "order:limit:" + userId; Boolean success = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (Boolean.FALSE.equals(success)) { throw new BusinessException(500, "操作太频繁,请稍后再试"); }这里用Redis的setIfAbsent(SETNX)做分布式锁,30秒后锁自动过期,比直接判断订单表里有没有30秒内的订单要高效得多。订单表加unique约束或Redis锁都能防止重复下单,但SETNX方案对大并发场景更友好。
热词里有人问了“springboot解决pdf xss攻击”,这个在点餐系统的订单详情或菜单报价单导出场景里也会遇到。核心思路就是过滤上传内容中的危险HTML标签和脚本,比如白名单校验、禁止加载外部实体、对文件名做严格校验等。在Spring Boot里,可以在全局异常处理器里统一拦截并转义危险字符,或者在文件上传接口里校验文件类型和内容,防止恶意代码进入服务器。
还有“springboot增加swagger”——这个强烈建议加上,特别是团队协作开发时。我用的是springfox(Spring Boot 2.7.x兼容3.0.0版本)或springdoc-openapi(Spring Boot 2.x使用springdoc-openapi-ui)。简单配置后,Swagger UI页面就能自动生成所有Controller的接口文档,前端开发同学可以直接在页面上查看参数和返回值,联调效率提升一倍不止。
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.7.0</version> </dependency>然后在启动类加一个@OpenAPIDefinition注解配置基础信息,访问/swagger-ui/index.html就能看到接口文档。上线前记得关闭Swagger,避免暴露接口详情被人恶意调用。
一点实际操作的体会:这个项目最大的收获不是“会写Spring Boot代码”或者“会配置uniapp”,而是理解了做一套完整产品的思路——从需求分析、技术选型、表结构设计、接口设计、前端开发、联调、测试、部署上线的完整闭环。每一步都有细节,每一步都可能踩坑。上面这些坑都是真实的开发过程中一步步踩出来的,如果你正在做类似的点餐小程序或者想从零开始做一个前后端分离的全栈项目,希望对你有用。如果有其他问题,欢迎在评论区交流。
本文还有配套的精品资源,点击获取