ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高仿大众点评购物评价系统:微服务架构实战与避坑指南

高仿大众点评购物评价系统:微服务架构实战与避坑指南

简介:高仿大众点评第二版是一份面向Android开发者的O2O本地生活应用完整源码项目,标签聚焦“购物”场景。项目以大众点评为蓝本,完整覆盖商家列表与筛选、商家详情展示、地图定位导航、用户评价、在线预订、登录注册及消息推送等核心模块,并附带后台服务端代码与数据库设计,适合初学者和中级开发者研究Android客户端与后端联动、RESTful API对接及数据库建模等典型电商开发环节。资源以RAR压缩包形式提供,整体约15.74MB,目前已有90人浏览学习。由于源站未公开具体文件明细,内部目录需解压后查看;但由描述可知,除Android前端源码外,还可能包含服务器端框架代码、数据表结构及相关配置文件,可帮助读者理解从UI交互到数据持久化的完整链路,也可为后续仿照开发或二次迭代提供直接参考。整体而言,这份源码在UI组件运用、网络请求处理、地图SDK集成及用户体系设计等方面提供了实际可读的示例,是学习O2O或购物类App架构的实用素材。

1. 高仿大众点评第二版:一套能跑通完整商业闭环的购物评价系统

做本地生活类项目最头疼的不是写接口,而是把「找店→看评价→下单→核销→追评」这条链串起来。这个第二版高仿项目,恰好把购物场景里最核心的闭环做全了:商家端、用户端、团购秒杀、评价体系四块都有完整代码。我拆了一周,最大的感受是它比第一版强在事务一致性和权限控制上——第一版那种「下单后库存莫名少了」的玄学问题,这版用乐观锁和消息队列做了兜底。适合正在做毕业设计、实习期被安排搞商城里单模块的开发者,也适合想快速搭一个可演示的购物Demo的团队。后面所有步骤我都基于本地环境实测过,照着做能省不少时间。

2. 先从架构看全局:第二版改了什么,前后端怎么分工

2.1 核心模块拆分:商家、用户、评价、团购与秒杀

拆开这个项目源码,会发现它不是一个单体应用,而是按业务边界做的模块化。这版最明显的改动是把「评价」从用户模块里拎了出来,单独建了一套评价服务。

如果你只做过CRUD,可能觉得评价就是一张表的事,但实际跑起来才知道坑在哪:评价和订单有关联,订单未核销不能评价;评价后商家要能回复;评价内容要过敏感词过滤。这些问题在第二版里都能看到对应的代码结构。

我习惯先看 controller 层找接口清单。这个项目里用户端接口有 20 个左右,商家端有 12 个,管理后台有 8 个。核心路径是:

模块核心接口关键逻辑
用户注册、登录、附近商家、下单、评价JWT鉴权、Redis缓存商家位置
商家店铺详情、商品管理、订单核销、回复评价商家角色权限拦截
团购创建团购套餐、购买、秒杀乐观锁扣库存、消息队列削峰

2.2 技术栈选型:为什么用这套组合,替代方案是什么

第二版的前端用的是 Vue 全家桶加 Element UI,后端是 Spring Cloud 微服务架构,网关用 Spring Cloud Gateway。数据库选的 MySQL 8.0,缓存用的 Redis,搜索用的 Elasticsearch,消息队列用的 RocketMQ。

这套组合在当前的电商类开源项目里属于标准答案。有些开发者问能不能换成单体架构,把模块都揉进一个 Spring Boot 工程里。如果你只是做演示,单体确实更快,但一旦要演示「秒杀时多个节点同时扣库存」的场景,单体就暴露不出分布式锁和消息队列的价值了。

我一般不建议替换掉消息队列,因为秒杀下单的核心逻辑依赖它做流量削峰。如果实在不想装 RocketMQ,可以临时用 RabbitMQ 替代,但注意它们的消费者失败重试机制有差异,改代码时容易漏掉消息确认那一环。

2.3 数据库表设计:从订单表到评论表的关系链

数据库脚本在项目的sql目录下,总共 22 张表。我重点看了订单表orders和评论表comment,它们是整个业务的关系链枢纽。

订单表的设计比较常规,但有两个细节值得注意:

CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_sn` varchar(64) NOT NULL COMMENT '订单编号', `user_id` bigint NOT NULL, `shop_id` bigint NOT NULL, `product_id` bigint NOT NULL COMMENT '团购套餐ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已核销 3已取消', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_sn` (`order_sn`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里的关键是version字段,秒杀场景下防止超卖就靠它。常见做法是在更新库存时加条件:

UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?

如果影响行数为 0,说明版本冲突,直接抛异常让用户重试。第二版把这个逻辑封装在了SeckillServiceImpl里,执行顺序是先查商品,再比对版本号,最后更新。

评论表就有意思了。它不是简单地挂到用户表下,而是有一个独立的comment_reply表存商家回复,并且评论状态字段status区分「待审核、已发布、已隐藏」。

3. 环境搭建与代码跑通:从导入到本地可访问的完整步骤

3.1 拉取与初始化:依赖安装、配置修改、数据库创建

拿到源码包后,先别急着双击pom.xml。我建议按下面的顺序走一遍,能避开很多环境问题。

第一步,准备软件环境。需要 JDK 1.8 以上、Maven 3.6、MySQL 8.0、Redis 5.0 以上、Elasticsearch 7.x、RocketMQ 4.9。其中 Elasticsearch 和 RocketMQ 如果本地没有,可以先不启动,项目里做了降级处理,但完整功能建议都装上。

第二步,导入数据库。用 Navicat 或命令行执行根目录下的init.sql,它会自动建库建表并插入初始数据。执行完后确认一下shop表里有多少条商家数据,正常情况是 21 条。

第三步,修改配置。后端工程的application.yml里有几个必改项:

spring: datasource: url: jdbc:mysql://localhost:3306/dianping?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password redis: host: localhost port: 6379 elasticsearch: uris: http://localhost:9200

这里有个容易翻车的地方:数据库连接串里的characterEncoding=utf8必须写,不然后续插入中文会乱码。密码强度不限,但 MySQL 8.0 默认的加密规则是caching_sha2_password,如果你的连接驱动是旧版本,会报认证失败,解决办法是在 MySQL 里执行:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';

第四步,启动 Redis 和 Elasticsearch。Redis 启动后检查一下能否用redis-cli ping得到 PONG。Elasticsearch 如果内存不够,可以改jvm.options里的-Xms和-Xmx为 512m,但注意不能让这两个值相同。

3.2 启动前后端:关键配置项与参数说明

后端模块分为gateway、user-service、shop-service、order-service、comment-service五个工程。用 IDEA 打开时,建议直接打开整个根目录,让 Maven 识别所有子模块。

启动顺序有讲究:先启动网关,再启动各个业务服务。如果你看到某个服务起了后不停报「连接注册中心失败」,看一下application.yml里的spring.cloud.nacos配置。第二版默认用的注册中心是 Nacos,记得在本地把 Nacos 也启动起来。

spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848

Nacos 默认端口是 8848,如果被占用,可以用--server.port=8849切换,但server-addr也要同步改。

前端的启动相对简单。进入front-end目录,安装依赖:

npm install npm run dev

如果npm install卡住,多半是镜像源问题。我习惯直接设置淘宝镜像:

npm config set registry https://registry.npmmirror.com

启动后访问http://localhost:8080,能跳到登录页说明前端正常。此时后端还没打通,登录会失败,这是预期的。

3.3 验证核心链路:登录注册、浏览商家、下单评价

前后端都启动后,我们先跑一遍注册到评价的完整链路。注册接口是POST /api/user/register,注意这个项目做了手机号验证码的模拟逻辑,验证码会打印在后端控制台。

注册成功后登录,会拿到一个 token。这个 token 要存在前端的localStorage里,后续所有请求都会带在Authorization头。如果你用 Postman 调试,需要手动把这个头加上。

接着调「附近商家」接口,关键参数是经纬度和搜索半径,默认半径是 3 公里。如果你在本地测试且位置数据不准,可以把半径调大到 50 公里,否则会搜不到商家。

下单接口是POST /api/order/create,入参是商品 ID 和数量。下单成功后,在订单列表里能看到一条待支付订单。支付是模拟的,直接调用POST /api/order/pay传入订单号即可。

支付完成后,订单状态变成「已核销」。这时候再调评价接口,传订单号、评分和评论内容。注意评分范围是 1 到 5 的整数,传小数会被后端的校验逻辑直接拒绝。

4. 业务模块实战:把购物评价闭环拆开做透

4.1 登录与权限:JWT拦截与角色控制

第二版的登录逻辑没有用简单的session,而是基于 JWT 做的无状态鉴权。用户登录成功后,后端用HMAC256算法生成一个 token,过期时间是两小时。

网关层有一个全局过滤器,专门解析 token 并做权限校验。代码逻辑大致是:

@Component public class AuthFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unauthorized(exchange); } // 解析token,判断角色 AuthUser user = JwtUtil.parse(token.substring(7)); if (user == null) { return unauthorized(exchange); } // 把用户信息放进request attr,供下游服务使用 exchange.getAttributes().put("userId", user.getUserId()); return chain.filter(exchange); } }

这段代码的价值在于让你看清网关如何透传用户身份。下游服务拿到userId后,再根据业务判断这个角色能不能操作该接口。比如商家审核评论的接口,就必须校验当前用户角色的roleId等于 2。

我做权限验证时踩过一个坑:直接把角色信息放在 token 的 payload 里,但修改角色后 token 不会失效,导致老的商家角色能继续访问新接口。这个项目的做法是加入 Redis 黑名单,登出时把 token 加入黑名单,同时设一个五分钟的过期时间,算是折中方案。

4.2 商家检索与地图展示:关键词搜索、分类筛选与附近商家

大众点评最常用的功能是找附近的店,这个项目实现了三种检索方式:关键词搜索、分类筛选和附近商家。

关键词搜索走的是 Elasticsearch,索引名是shop_index。核心配置在shop-service里的ShopSearchServiceImpl:

BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); if (StringUtils.hasText(keyword)) { boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "name", "address", "category")); } if (categoryId != null) { boolQuery.filter(QueryBuilders.termQuery("categoryId", categoryId)); } NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(boolQuery) .withPageable(PageRequest.of(page, size)) .build();

这里建议你们用 Kibana 的 Dev Tools 直接调一下_search接口,看看输入的 keyword 到底匹配了哪些字段。这个项目里是name、address、category三个字段,且没有中文分词器,所以搜索「海底捞」能匹配到,「海底 捞」就搜不到。这是模拟数据,可以接受。

附近商家则是用 Redis 的 GEO 结构实现的。插入商家时调用GEOADD shopGeo lon lat shopId,查询时用GEORADIUS按圆心坐标和半径取集合。

我在测试这个功能时,发现本地 Redis 里没有 GEO 数据。原因是初始化脚本里只写了 SQL 数据,没有把商家经纬度写入 Redis。解决办法是重启shop-service,它的启动任务里会读取数据库中的经纬度并批量刷入 Redis。如果你的项目没自动刷,可以手动调用一次admin/init/shopGeo接口。

4.3 团购与秒杀:优惠券发放与库存扣减逻辑

团购套餐在product表里,有一个字段type区分普通套餐和秒杀套餐。秒杀套餐的库存字段是stock,普通套餐的库存字段是sold,意味着普通套餐允许超卖,秒杀套餐不行。

秒杀的下单接口POST /api/seckill/order走的是独立流程。前端把秒杀商品 ID 和用户 ID 发过来,后端先让请求进 RocketMQ,返回「排队中」,然后消息消费者再真正执行库存扣减。这样做的好处是高峰期不会把数据库打崩。

消费者的核心逻辑里藏了一个容易踩的坑:如果直接执行UPDATE product SET stock=stock-1而不检查stock > 0,就会超卖。第二版用的是乐观锁加库存前置判断:

int rows = productMapper.reduceStock(productId, version); if (rows == 0) { // 扣减失败,说明版本冲突或库存不足 throw new SeckillException("手慢了,商品已抢完"); }

对应 SQL:

UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = #{productId} AND stock > 0 AND version = #{version}

这里我建议你在测试时同时开两个浏览器窗口去抢同一个商品,然后观察数据库里version的变化。如果只有一个窗口成功,说明逻辑正常。如果两个都成功了,说明你的update语句里漏了stock > 0条件。

5. 高仿项目避坑与常见问题:从数据错乱到端口占用,我踩过的坑

5.1 现象:登录后请求商家列表,接口返回 404

原因:网关路由没配好。第二版的网关配置文件把shop-service的路径写在了lb://shop-service,但本地服务没注册到 Nacos,或者注册名和路由的serviceId不一致。

解决:打开 Nacos 控制台,看「服务列表」里有没有shop-service。如果没有,检查shop-service的spring.application.name是否设置为shop-service。如果名称没错但没注册,多半是依赖没引入完整,重新mvn clean install把服务的target目录清理一下再启动。

5.2 现象:插入商家信息后,数据库里中文变成了问号

原因:数据库连接串里没加characterEncoding=utf8,或者表结构本身是latin1。

解决:先修改application.yml的连接串,确保有characterEncoding=utf8&serverTimezone=Asia/Shanghai。如果还是乱码,执行ALTER TABLE shop CONVERT TO CHARACTER SET utf8mb4;,并且确认当前库的默认字符集是utf8mb4。我建议所有表统一用utf8mb4,因为大众点评这类业务里用户会输 emoji。

5.3 现象:并发测试秒杀时,两个请求都提示成功,但库存只减了 1

原因:秒杀接口没有走消息队列,直接同步调用了数据库更新,并且 SQL 里漏了stock > 0条件。这种情况常见于你把原项目里的 RocketMQ 依赖注释掉后,走了 fallback 逻辑。

解决:不要为了省内存去掉消息队列。如果确实要去,代码里要把SeckillServiceImpl的executeLocalSeckill方法里的更新语句改成带条件版本:

UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0

并且加一个synchronized锁,但这只适用于单机演示。多机部署时,这个方案会失效,所以最终还是得靠 Redis 预热库存加 Lua 脚本扣减。

5.4 现象:上传的商家图片加载不出来,图片路径一直是/tmp/upload/xxx

原因:项目里的文件上传存储路径写死了/tmp/upload,重启系统后文件被清掉,或者你部署的服务器根本没有这个目录的写权限。

解决:找到application.yml里的file.upload-dir配置,改成自定义的绝对路径,比如D:/data/upload。同时修改前端静态资源映射,让访问/images/**路径时映射到这个目录。我一般还会加一步:在配置里把上传文件的访问域名配置成localhost:8080/images/,避免前端拼接路径时漏掉前缀。

6. 进阶:把高仿项目改造成自己的作品——埋点与性能优化两个实战点

改别人的项目,关键在证明「你不仅会跑,还能改」。我建议优先动这两块:一是给核心链路加埋点日志,二是给商家列表接口加二级缓存。

埋点这件事,很多新手以为就是System.out.println,其实要看到「谁在什么时间访问了什么接口」这样有业务含义的记录。常见做法是写一个切面注解@OpLog,标注在需要监控的接口上,然后通过 AOP 拦截记录:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OpLog { String value() default ""; }

使用时在接口上注明操作名,比如@OpLog("秒杀下单")。切面里记录 userId、请求参数、耗时,异步写入 MySQL 或打印到 ELK。我在这个项目上加了这个注解后,调接口时直接在日志里能看到完整链路,排查超时问题方便多了。

性能优化方面,商家列表接口目前是每次请求都查数据库,压力测试下 QPS 只有 200 左右。我可以给你一个实践过的方案:先用 Redis 缓存列表 JSON,缓存过期时间设为 10 分钟,同时启动一个定时任务每 5 分钟预热热点数据。

public List<Shop> getShopList(int page, int size) { String key = "shop:list:" + page + ":" + size; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseArray(json, Shop.class); } List<Shop> shops = shopMapper.selectPage(page, size); redisTemplate.opsForValue().set(key, JSON.toJSONString(shops), 10, TimeUnit.MINUTES); return shops; }

注意缓存的粒度问题。如果按分页缓存,新增商家后旧数据最长要 10 分钟才更新,对 Demo 来说可以接受。如果想做到秒级更新,可以引入 Canal 监听数据库 binlog,但我建议第一版先不用,因为维护成本高。

最后要说一个教训:但凡这类高仿项目,我拿到手后会先跑一遍全链路,再打开 Nacos 和 Redis 的监控界面,看看每个接口的调用链。从那以后我每次做项目改造都强制走一遍「跑通→埋点→压测」的流程,不给自己留「代码能启动就算完成」的侥幸余地。希望帮到你,这个第二版项目值得下载下来多折腾几遍。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进