简介:这是一套面向Android开发者进阶后端技能的全栈小商城实战项目,涵盖客户端与服务端完整实现,适合具备Java基础、希望掌握SpringBoot+Mybatis主流后端技术栈的初学者系统学习。资源包含Android端(MVC架构)与Java后端(SpringBoot+Mybatis+Redis分布式缓存)双模块源码,支持下单、购物车、支付宝支付等核心电商功能,并附带数据库脚本与详细项目说明文档。压缩包共270个文件,以164个Java源码(含Controller/Service/Mapper层)、74个XML配置(MyBatis映射与Generator模板)、4个properties/yml配置文件及SQL建表脚本为主,辅以Gradle构建文件、SDK依赖jar包与UI资源,整体4.63MB,结构清晰、模块解耦,便于分层理解与二次开发。目前已有2260人学习下载,提供从网络请求(OkGo)、屏幕适配(AutoSize)、权限管理(RxPermissions)到分页(PageHelper)、代码生成(MyBatisGenerator)等一线开发实践细节,是少有的兼顾Android与后端协同开发的轻量级教学级项目。
1. 一个能跑通、能调试、能改需求的小商店全栈项目,为什么值得你花20分钟搭一遍?
很多刚学完 Java 基础和 Android 开发的同学,卡在「知道每个技术点,但拼不出完整系统」这一步。你可能写过 Spring Boot 的 HelloController,也用 RecyclerView 展示过商品列表,但当「用户登录 → 查看商品 → 加入购物车 → 提交订单 → 后端扣库存 → Android 端显示支付成功」这一整条链路要串起来时,就发现:数据库字段对不上、API 返回格式不一致、Token 传丢了、MyBatis 的 resultMap 没配对、Android 端 Retrofit 接口定义和后端 Controller 方法签名不匹配……这些不是理论问题,是工程落地的毛细血管级断点。
这个标题里的「Android+Java后端(Springboot+Mybatis)小商店项目」,本质是一个最小可行闭环(MVC+REST+SQLite/MySQL 双端协同):它不追求高并发或微服务,但强制覆盖了身份认证(JWT)、商品CRUD、购物车本地+服务端同步、订单状态机、前后端时间戳与ID生成一致性等真实业务中绕不开的细节。尤其适合两类人:一是准备 Java 或 Android 方向校招面试者,可直接拆解为「MyBatis 动态SQL怎么写」「Spring Boot 如何统一返回体」「Android 如何安全存储 Token」等高频考点;二是想快速验证自己技术栈整合能力的中级开发者——它不藏私,所有接口契约明确定义在api.md或 Controller 注释里,数据库建表语句完整,连application.yml中 MySQL 连接池的max-active: 8都写了注释说明依据。
提示:这不是玩具项目。它包含真实业务约束:比如「下单时校验商品库存是否大于等于购买数量」在 Service 层有显式
if (stock < quantity) throw new BusinessException("库存不足"),且该异常被全局@ControllerAdvice捕获并转为标准 JSON 错误响应;Android 端对应地做了 Toast 提示和按钮置灰防重复提交。这种「错误路径全覆盖」的设计,才是工程化项目的分水岭。
2. 从零启动:本地运行这个小商店项目的5个关键步骤与参数含义
要让这个 ZIP 包里的项目真正活起来,不能只解压双击运行。必须理解每个环节的职责边界和协作契约。下面按执行顺序拆解,每步都给出命令、配置项说明及失败时的定位线索。
2.1 数据库初始化:用 SQL 脚本创建表结构,而非依赖 Hibernate 自动建表
Spring Boot + MyBatis 组合默认不开启自动建表(区别于 JPA),这是刻意为之的工程实践:生产环境严禁 ORM 自动变更 DDL。项目提供的shop_db.sql是唯一可信的数据源。
-- shop_db.sql 片段(MySQL 8.0+ 兼容) CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(64) NOT NULL COMMENT '用户名', `password` varchar(128) NOT NULL COMMENT 'BCrypt 加密后的密码', `phone` varchar(16) DEFAULT NULL COMMENT '手机号', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;注意:脚本中
ENGINE=InnoDB和CHARSET=utf8mb4是硬性要求。若用 MySQL 5.7 且未开启innodb_file_per_table=ON,建表可能静默失败;若字符集设为utf8(非utf8mb4),后续插入 emoji 或四字节中文会报错Incorrect string value。执行前务必确认:mysql --version # 必须 ≥ 5.7 mysql -u root -p -e "SHOW VARIABLES LIKE 'character_set%';"
2.1.1 创建数据库并导入数据
# 1. 登录 MySQL 创建数据库(注意指定字符集) mysql -u root -p -e "CREATE DATABASE shop_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 2. 导入 SQL 脚本(确保路径正确) mysql -u root -p shop_db < /path/to/shop_db.sql # 3. 验证表是否创建成功(关键!) mysql -u root -p -D shop_db -e "SHOW TABLES;" # 应输出:t_cart, t_goods, t_order, t_order_item, t_user 等 5 张表提示:如果
SHOW TABLES为空,不要急着重试。先检查shop_db.sql文件头是否有USE shop_db;—— 若有,需删除该行再导入,否则在非 shop_db 数据库下执行会静默失败。这是新手最常踩的坑。
2.2 后端启动:Spring Boot 配置文件中的 3 个必调参数
解压后端模块(通常为server/或springboot-server/目录),打开src/main/resources/application.yml。以下三个参数必须根据你的本地环境修改:
| 参数 | 默认值 | 必须修改原因 | 示例值 |
|---|---|---|---|
spring.datasource.url | jdbc:mysql://localhost:3306/shop_db?useSSL=false&serverTimezone=Asia/Shanghai | 若 MySQL 端口不是 3306,或数据库名不是shop_db,连接直接失败 | jdbc:mysql://127.0.0.1:3307/shop_db?... |
spring.datasource.username | root | 若 MySQL 设置了非 root 用户,此处需同步 | shop_user |
mybatis.configuration.map-underscore-to-camel-case | true | 决定 MyBatis 是否自动转换下划线字段名到驼峰属性名。若设为 false,t_user.username就无法映射到User.getUsername(),所有查询返回 null | true(保持默认) |
# application.yml 关键片段 spring: datasource: url: jdbc:mysql://localhost:3306/shop_db?useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_mysql_password # 明文密码仅限本地开发! driver-class-name: com.mysql.cj.jdbc.Driver mybatis: configuration: map-underscore-to-camel-case: true # 此开关影响所有实体类字段映射 mapper-locations: classpath:mapper/*.xml注意:
password字段若含特殊字符(如@,/,:),需用 URL 编码。例如密码为p@ss/w0rd,应写为p%40ss%2Fw0rd。否则 Spring Boot 解析 JDBC URL 时会将@误认为主机分隔符。
2.2.2 启动后端并验证 API 可达性
# 在 server/ 目录下执行(确保已安装 JDK 11+) mvn clean spring-boot:run # 启动成功后,终端应出现: # Tomcat started on port(s): 8080 (http) with context path '' # Started ShopApplication in X.XXX seconds (JVM running for Y.YYY) # 验证基础接口(用 curl 或浏览器访问) curl -X GET http://localhost:8080/api/goods/list # 正常响应:{"code":200,"msg":"success","data":[{"id":1,"name":"iPhone 15","price":5999.0}]}提示:若返回
Whitelabel Error Page,首先检查http://localhost:8080/actuator/health是否返回{"status":"UP"}。若健康检查失败,说明数据库连接未通,回溯 2.1 步骤;若健康检查通过但业务接口 404,检查@RestController类是否在@SpringBootApplication扫描包路径内(默认扫描启动类所在包及子包)。
2.3 Android 端配置:替换 BaseUrl 与启用网络权限
Android 项目(通常为app/模块)需两处硬编码修改,否则请求全部超时:
2.3.1 修改 API 基地址
打开app/src/main/java/com/example/shop/network/ApiService.java(或类似路径),找到 Retrofit 实例构建处:
// 错误写法(写死 IP,无法跨设备调试) public static final String BASE_URL = "http://192.168.1.100:8080/"; // 正确写法(适配本机回环 + USB 调试) public static final String BASE_URL = "http://10.0.2.2:8080/"; // Android 模拟器访问宿主机 // 或 public static final String BASE_URL = "http://192.168.x.x:8080/"; // 真机调试时,填电脑局域网 IP提示:
10.0.2.2是 Android Studio 模拟器预设的宿主机别名,真机调试必须用电脑的局域网 IP(如192.168.31.123)。在电脑终端执行ipconfig(Windows)或ifconfig | grep "inet "(Mac/Linux)获取。切勿用localhost或127.0.0.1,Android 设备无法解析。
2.3.2 声明网络权限并允许 HTTP 明文流量
在app/src/main/AndroidManifest.xml的<application>标签内添加:
<application android:usesCleartextTraffic="true" <!-- 允许 HTTP 请求(开发阶段必需) --> ... > <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> </application>注意:
android:usesCleartextTraffic="true"是开发阶段的妥协。上线前必须切换为 HTTPS,并在res/xml/network_security_config.xml中配置证书固定(Certificate Pinning)。此配置仅用于本地联调。
2.4 运行 Android App:解决 Gradle 同步与依赖冲突
Android Studio 打开项目后,首次同步常因依赖版本不兼容报错。核心矛盾在于:项目使用较老的com.android.tools.build:gradle:3.6.4(对应 Gradle 5.6.4),而新版本 AS 默认推荐 8.x。
2.4.1 强制降级 Gradle 版本
修改项目根目录下的gradle/wrapper/gradle-wrapper.properties:
# 将 distributionUrl 改为项目兼容版本 distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip2.4.2 处理常见的依赖冲突
若同步时报错Duplicate class android.arch.lifecycle...,说明引入了 AndroidX 与旧 Support Library 混用。在app/build.gradle中添加:
android { compileSdkVersion 29 // 项目通常基于 Android 10 defaultConfig { targetSdkVersion 29 // 添加以下两行强制迁移至 AndroidX android.useAndroidX=true android.enableJetifier=true } } dependencies { // 替换所有 support 库为 androidx 对应版本 implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.1.0' // 移除所有以 'com.android.support:' 开头的依赖 }提示:若
Build > Clean Project后仍报Cannot resolve symbol R,右键app模块 →Load project,强制重新索引资源。这是 AS 缓存导致的假性错误。
3. 深度调试:MyBatis 与 Android 网络层的 3 个典型问题定位方法
当界面空白、按钮无响应、日志无报错时,问题往往藏在数据流转的缝隙里。这里给出针对本项目架构的精准排查路径。
3.1 后端 MyBatis 查询结果为空?先查 SQL 日志与实体映射
MyBatis 默认不打印 SQL,需主动开启。在application.yml中添加:
logging: level: com.example.shop.mapper: debug # 指定 Mapper 接口包路径 org.springframework.jdbc.core.JdbcTemplate: debug启动后,控制台将输出类似内容:
==> Preparing: SELECT id, name, price, stock FROM t_goods WHERE status = ? ==> Parameters: 1(Integer) <== Columns: id, name, price, stock <== Row: 1, iPhone 15, 5999.0, 100 <== Total: 1注意:若看到
Parameters但无Row输出,说明 SQL 执行了但没查到数据。此时检查WHERE条件(如status = 1是否与数据库中t_goods.status值一致);若连Preparing都没有,说明 Mapper 接口方法未被调用,检查 Controller 层@Autowired的 Service 是否注入成功(IDE 会标黄提示)。
3.1.1 实体类字段与数据库列名不匹配的 2 种修复方式
假设数据库列名为goods_name,而 Java 实体类属性为goodsName:
方式一(推荐):用 @Results 注解显式映射
@Select("SELECT id, goods_name, price FROM t_goods") @Results({ @Result(property = "goodsName", column = "goods_name"), @Result(property = "id", column = "id") }) List<Goods> selectAll();方式二:在全局配置中开启自动映射(需确保 mybatis.configuration.map-underscore-to-camel-case: true)
// Goods.java public class Goods { private Long id; private String goodsName; // 自动匹配 goods_name private BigDecimal price; }提示:
map-underscore-to-camel-case: true仅对列名到属性名生效,对@Param("goodsName")中的参数名无效。若 DAO 方法用@Param传参,SQL 中仍需写#{goodsName}。
3.2 Android 端 Retrofit 接口返回 null?检查泛型擦除与 JSON 解析
Retrofit 默认用 Gson 解析 JSON。若后端返回{"code":200,"data":[{"id":1,"name":"A"}]},而 Android 端定义为:
// 错误:泛型被擦除,Gson 不知 data 内部是 Goods 列表 Call<BaseResponse> getGoodsList(); // 正确:用 TypeToken 保留泛型信息 Type type = new TypeToken<BaseResponse<List<Goods>>>(){}.getType(); Call<BaseResponse<List<Goods>>> call = apiService.getGoodsList();更稳妥的做法是定义具体泛型响应类:
public class BaseResponse<T> { private int code; private String msg; private T data; // getter/setter } // 接口定义 @GET("api/goods/list") Call<BaseResponse<List<Goods>>> getGoodsList();3.2.1 查看真实网络请求与响应(无需抓包工具)
在OkHttpClient构建时添加日志拦截器:
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); // 打印请求/响应体 OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(logging) .build();运行 App 后,Logcat 中搜索OkHttp,可见:
--> GET http://10.0.2.2:8080/api/goods/list --> END GET <-- 200 http://10.0.2.2:8080/api/goods/list (123ms) {"code":200,"msg":"success","data":[{"id":1,"name":"iPhone 15","price":5999.0}]} <-- END HTTP注意:若看到
{"code":500,"msg":"Internal Server Error"},说明后端抛出未捕获异常,回溯 3.1 步骤查日志;若看到java.net.ConnectException: Failed to connect to /10.0.2.2:8080,检查后端是否运行、防火墙是否放行 8080 端口、IP 地址是否填错。
3.3 购物车数据不同步?理解本地 SQLite 与服务端 REST 的协同逻辑
本项目购物车采用「混合存储」:未登录时数据存 Android 本地 SQLite(CartDao),登录后同步到服务端t_cart表。关键逻辑在CartManager.java:
public void addToCart(Goods goods) { if (isLogin()) { // 已登录:调用 API 添加到服务端 apiService.addCart(goods.getId(), goods.getCount()) .enqueue(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { syncLocalCartToServer(); // 同步完成后,再拉取最新服务端数据覆盖本地 } }); } else { // 未登录:存入本地 SQLite cartDao.insert(new CartItem(goods)); } }3.3.1 调试购物车同步的黄金三步法
确认本地 SQLite 是否写入
在 Android Studio 的Device File Explorer中,路径:/data/data/com.example.shop/databases/cart.db→ 右键Save As导出,用 DB Browser for SQLite 打开查看cart_items表。确认服务端 t_cart 表是否更新
直接执行 SQL:SELECT * FROM t_cart WHERE user_id = 1;(user_id 为当前登录用户 ID)。比对两端数据一致性
若本地有 3 条,服务端只有 1 条,说明syncLocalCartToServer()方法未执行或执行失败。在该方法首行加断点,观察cartDao.getAll()返回值是否为空。
提示:
syncLocalCartToServer()内部会先清空服务端购物车再批量插入,这是为避免重复商品。若网络中断导致清空成功但插入失败,会造成数据丢失。生产环境应改为「幂等更新」(如用INSERT ... ON DUPLICATE KEY UPDATE)。
4. 进阶实战:为小商店项目添加「微信支付回调通知」的完整链路
支付功能是电商类项目的标志性能力。本节以微信支付沙箱环境为例,演示如何在现有 Spring Boot + MyBatis 架构上安全接入支付回调,不改动原有订单核心逻辑。
4.1 微信支付沙箱环境配置与密钥生成
微信支付沙箱无需企业资质,专为开发测试设计。登录 微信支付商户平台 →「开发配置」→「沙箱环境」→「APIv3 密钥」:
- 下载
apiclient_key.pem(私钥)和apiclient_cert.pem(证书) - 在
application.yml中新增配置:
wechat: pay: mch-id: 1900000109 # 沙箱商户号 app-id: wx8888888888888888 # 沙箱 AppID notify-url: http://your-ngrok-domain.com/api/pay/callback # 外网可访问地址 key-path: classpath:apiclient_key.pem cert-path: classpath:apiclient_cert.pem注意:
notify-url必须是公网地址。本地开发用ngrok或localtunnel映射:ngrok http 8080→ 获取https://abc123.ngrok.io,填入notify-url。
4.2 编写支付回调 Controller:验证签名 + 更新订单状态
微信支付回调是 POST 请求,Body 为加密 JSON。Spring Boot 需用@RequestBody接收原始流:
@PostMapping("/api/pay/callback") public ResponseEntity<String> handlePayCallback(HttpServletRequest request) throws Exception { // 1. 读取原始请求体(关键!不能用 @RequestBody,会破坏签名验证) String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // 2. 验证签名(微信提供官方 SDK) boolean isValid = WechatPayValidatorForV3.verify( request.getHeader("Wechatpay-Signature"), request.getHeader("Wechatpay-Timestamp"), request.getHeader("Wechatpay-Nonce"), request.getHeader("Wechatpay-Serial"), body ); if (!isValid) { return ResponseEntity.status(401).body("Invalid signature"); } // 3. 解密回调内容(微信用 AES-GCM 加密) String decryptData = AesUtil.decryptToString( Files.readAllBytes(Paths.get("classpath:apiclient_key.pem")), "MD5", // 加密算法 body ); // 4. 解析 JSON 并更新订单 JSONObject json = new JSONObject(decryptData); String outTradeNo = json.getJSONObject("resource").getString("out_trade_no"); String transactionId = json.getJSONObject("resource").getString("transaction_id"); orderService.updateOrderStatus(outTradeNo, OrderStatus.PAID, transactionId); return ResponseEntity.ok("SUCCESS"); // 必须返回纯文本 SUCCESS }4.2.1 订单状态更新的 MyBatis 实现
在OrderMapper.xml中编写条件更新语句,确保幂等性:
<update id="updateOrderStatus"> UPDATE t_order SET status = #{status}, pay_time = NOW(), transaction_id = #{transactionId} WHERE order_no = #{orderNo} AND status = #{oldStatus} <!-- 防止重复支付导致状态回滚 --> </update>提示:
AND status = #{oldStatus}是关键。若订单已是PAID,此 SQL 影响行数为 0,updateOrderStatus()返回0,可记录告警日志但不抛异常,保证回调接口高可用。
4.3 Android 端发起支付:调用统一下单 API 并唤起微信
支付流程分两步:后端调用微信统一下单 API 获取prepay_id,Android 端用该 ID 唤起微信 App。
4.3.1 后端统一下单接口(供 Android 调用)
@PostMapping("/api/pay/unifiedorder") public Result<Map<String, String>> unifiedOrder(@RequestBody PayRequest request) { // 1. 校验订单合法性(是否存在、未支付) Order order = orderService.getByOrderNo(request.getOrderNo()); if (order == null || !order.getStatus().equals(OrderStatus.UNPAID)) { return Result.fail("订单不存在或已支付"); } // 2. 构造微信统一下单参数 Map<String, String> params = new HashMap<>(); params.put("appid", wechatPayProperties.getAppId()); params.put("mch_id", wechatPayProperties.getMchId()); params.put("nonce_str", UUID.randomUUID().toString().replace("-", "")); params.put("body", "小商店订单"); params.put("out_trade_no", request.getOrderNo()); params.put("total_fee", String.valueOf(order.getTotalPrice().multiply(new BigDecimal("100")).longValue())); // 单位:分 params.put("spbill_create_ip", "127.0.0.1"); params.put("notify_url", wechatPayProperties.getNotifyUrl()); params.put("trade_type", "APP"); // 3. 签名并发送请求(微信 SDK) String sign = WXPayUtil.generateSignature(params, wechatPayProperties.getKey()); params.put("sign", sign); String resultXml = WXPayUtil.postXml("https://api.mch.weixin.qq.com/pay/unifiedorder", params); Map<String, String> respMap = WXPayUtil.xmlToMap(resultXml); if ("SUCCESS".equals(respMap.get("return_code")) && "SUCCESS".equals(respMap.get("result_code"))) { Map<String, String> payParams = new HashMap<>(); payParams.put("appid", respMap.get("appid")); payParams.put("partnerid", respMap.get("mch_id")); payParams.put("prepayid", respMap.get("prepay_id")); payParams.put("package", "Sign=WXPay"); payParams.put("noncestr", respMap.get("nonce_str")); payParams.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000)); payParams.put("sign", WXPayUtil.generateSignature(payParams, wechatPayProperties.getKey())); return Result.success(payParams); } else { return Result.fail(respMap.get("err_code_des")); } }4.3.2 Android 端集成微信 SDK 唤起支付
在app/build.gradle中添加依赖:
implementation 'com.tencent.mm.opensdk:wechat-sdk-android-without-mta:+'调用逻辑:
// 1. 调用后端 /api/pay/unifiedorder 获取 payParams apiService.unifiedOrder(orderNo).enqueue(new Callback<Map<String, String>>() { @Override public void onResponse(Call<Map<String, String>> call, Response<Map<String, String>> response) { if (response.isSuccessful()) { Map<String, String> payParams = response.body(); // 2. 构造 PayReq PayReq req = new PayReq(); req.appId = payParams.get("appid"); req.partnerId = payParams.get("partnerid"); req.prepayId = payParams.get("prepayid"); req.packageValue = payParams.get("package"); req.nonceStr = payParams.get("noncestr"); req.timeStamp = payParams.get("timestamp"); req.sign = payParams.get("sign"); // 3. 唤起微信 IWXAPI api = WXAPIFactory.createWXAPI(this, req.appId); api.registerApp(req.appId); api.sendReq(req); } } });提示:微信 SDK 要求
appid必须与 AndroidManifest 中注册的com.tencent.mm.sdk.openapi.WXEntryActivity的android:exported="true"一致,且WXEntryActivity必须继承WXCallbackActivity。若唤起失败,检查AndroidManifest.xml中<activity>声明是否遗漏exported="true"(Android 12+ 强制要求)。
至此,一个具备真实支付闭环的小商店项目已完全打通。你不仅跑通了 ZIP 包里的代码,更掌握了从数据库建模、前后端联调、异常定位到第三方服务集成的全链路工程能力。
本文还有配套的精品资源,点击获取