后端开发场景集 — 5个真实工作场景

从需求到上线,每个场景都是一次完整的实战推演

SQL优化 大数据量导出 第三方支付 定时任务 分片上传

这不是面试八股文。你将看到后端开发者在真实工作中遇到问题、分析问题、解决问题的全过程。

场景 1
🔍 多条件组合查询接口

场景:产品经理说"订单列表要加10个筛选条件"

1.1 收到需求
💬 需求讨论群 (5)
—— 2026-03-20 10:15 ——
产品小赵
订单列表页要加筛选功能,条件有:
1. 订单状态
2. 时间范围
3. 客户名
4. 金额范围
5. 支付方式
6. 商品类型
7. 是否退款
8. 快递公司
9. 地区
10. 关键词搜索
小张(后端)
10个条件?确认一下:都是AND关系吗?有没有必须填的?
产品小赵
都是AND关系,全部选填。用户可以只填其中几个,也可以全部填
小张(后端)
明白了。10个可选条件,那SQL不能用硬编码拼接,得用动态SQL
10个可选条件意味着2^10 = 1024种组合,硬编码WHERE子句是不可能的。必须用MyBatis的 <where> + <if> 动态拼接。
核心机制:MyBatis动态SQL的 <where><if> 标签实现了"按条件拼接SQL"的能力。<where> 自动处理首个条件的AND/OR前缀问题(避免 SQL 语法错误),<if> 根据参数是否为空决定是否拼接该条件片段。这样前端传几个筛选条件,SQL就动态拼接几个 WHERE 子句,不需要为每种组合写一条SQL。

1.2 设计SQL — 先看"反面教材"

新手容易写出这样的代码——把所有条件硬编码到SQL里:

DataGrip — t_order ⚠ 反面教材
-- ❌ 错误做法:条件写死,只要有一个条件为空就查不出数据 SELECT * FROM t_order WHERE status = ? AND create_time BETWEEN ? AND ? AND customer_name LIKE ? AND amount BETWEEN ? AND ? AND pay_method = ? AND ... -- 10个条件全写死
问题:用户只填了"订单状态"一个条件,其他9个都是null,SQL会变成 WHERE status = 2 AND create_time BETWEEN null AND null — 直接报错或查不出数据。

有人可能会想:那我在Java代码里用if-else拼接SQL字符串呢?

OrderService.java — ⚠ 另一个反面教材
OrderService.java
📂 service
📄 OrderService.java
1// ❌ 手工拼接SQL,容易出错、容易SQL注入
2String sql = "SELECT * FROM t_order WHERE 1=1";
3if (status != null) sql += " AND status = " + status; // SQL注入风险!
4if (customerName != null) sql += " AND name LIKE '%" + customerName + "%'";
5// 10个条件...30行if-else...维护噩梦
三种错误做法:1) 条件硬编码 → 有null就报错 2) Java拼接SQL字符串 → SQL注入风险 3) 10个条件的if-else → 维护噩梦
唯一正确做法:MyBatis动态SQL的 <where> + <if>

正确做法 — MyBatis动态SQL:

OrderMapper.xml — order-service
OrderMapper.xml
OrderMapper.java
OrderService.java
📂 mapper
📄 OrderMapper.xml
📄 OrderMapper.java
📂 service
📄 OrderService.java
📂 dto
📄 OrderQueryDTO.java
1<select id="searchOrders" resultType="OrderVO">
2 SELECT o.*, c.name as customer_name
3 FROM t_order o
4 LEFT JOIN t_customer c ON o.customer_id = c.id
5 <where>
6 <if test="status != null">
7 AND o.status = #{status}
8 </if>
9 <if test="startTime != null">
10 AND o.create_time >= #{startTime}
11 </if>
12 <if test="endTime != null">
13 AND o.create_time <= #{endTime}
14 </if>
15 <if test="customerName != null">
16 AND c.name LIKE CONCAT('%',#{customerName},'%')
17 </if>
18 <if test="minAmount != null">
19 AND o.amount >= #{minAmount}
20 </if>
21 <if test="maxAmount != null">
22 AND o.amount <= #{maxAmount}
23 </if>
24 <if test="payMethod != null">
25 AND o.pay_method = #{payMethod}
26 </if>
27 <if test="productType != null">
28 AND EXISTS (
29 SELECT 1 FROM t_order_item oi
30 JOIN t_product p ON oi.product_id = p.id
31 WHERE oi.order_id = o.id
32 AND p.type = #{productType}
33 )
34 </if>
35 <if test="hasRefund != null">
36 AND o.has_refund = #{hasRefund}
37 </if>
38 <if test="keyword != null">
39 AND (o.order_no LIKE CONCAT('%',#{keyword},'%')
40 OR c.name LIKE CONCAT('%',#{keyword},'%'))
41 </if>
42 </where>
43 ORDER BY o.create_time DESC
44 LIMIT #{offset}, #{size}
45</select>
UTF-8 XML ✓ No issues found
关键点:<where> 标签会自动处理第一个条件前面的 AND,避免了 WHERE AND ... 的语法错误。当所有条件都为空时,SQL会变成 SELECT ... FROM ... ORDER BY ... — 直接返回全部数据。

小张还写了对应的查询DTO:

OrderQueryDTO.java — order-service
OrderMapper.xml
OrderQueryDTO.java
📂 dto
📄 OrderQueryDTO.java
1@Data
2public class OrderQueryDTO {
3 private Integer status; // 订单状态
4 private LocalDateTime startTime; // 开始时间
5 private LocalDateTime endTime; // 结束时间
6 private String customerName; // 客户名(模糊)
7 private BigDecimal minAmount; // 最小金额
8 private BigDecimal maxAmount; // 最大金额
9 private String payMethod; // 支付方式
10 private Integer productType; // 商品类型
11 private Boolean hasRefund; // 是否退款
12 private String keyword; // 关键词搜索
13 private Integer page = 1;
14 private Integer size = 10;
15}
DTO里所有字段都是包装类型(Integer/String)而不是基本类型(int),这样null值才能正确判断"用户是否填了这个条件"

对应的Controller:

OrderController.java — order-service
OrderMapper.xml
OrderController.java
📂 controller
📄 OrderController.java
📂 service
📄 OrderService.java
1@RestController
2@RequestMapping("/api/orders")
3public class OrderController {
4
5 @GetMapping("/search")
6 public Result<PageResult<OrderVO>> search(OrderQueryDTO query) {
7 // SpringMVC自动把请求参数绑定到DTO
8 // 不传的字段自动为null,MyBatis <if> 会跳过
9 return Result.success(orderService.searchOrders(query));
10 }
11}
注意Controller方法的参数是 OrderQueryDTO 而不是10个 @RequestParam。如果用10个 @RequestParam,方法签名会非常丑陋:search(Integer status, LocalDateTime start, LocalDateTime end, String customerName, BigDecimal min, ...)。DTO封装让代码更干净,也方便后续加字段。

1.3 加索引 — EXPLAIN 验证

写完SQL,小张打开DataGrip跑一下EXPLAIN,看看索引是否生效:

DataGrip — shop_db
EXPLAIN SELECT o.* FROM t_order o WHERE o.status = 2 AND o.create_time >= '2026-03-01';
id
type
key
rows
Extra
1
ref
idx_status_create_time
1,230
Using index condition
type=ref — 索引生效了!组合索引 idx_status_create_time 命中,只扫描了1230行,而不是全表扫描。
DataGrip — 建索引
-- 组合索引:区分度高的字段放前面 CREATE INDEX idx_status_create_time ON t_order(status, create_time); CREATE INDEX idx_pay_method ON t_order(pay_method); CREATE INDEX idx_customer_name ON t_customer(name);
索引策略:多条件查询的索引策略:把区分度高的字段放前面,组合索引遵循最左前缀原则。不要每个字段都建单列索引 — MySQL优化器通常只会选一个索引使用。

1.4 自测接口

小张打开Postman,先测试填了3个条件的情况:

GET
/api/orders?status=2&startTime=2026-03-01&endTime=2026-03-20&page=1&size=10
Postman
Query Params
status = 2
startTime = 2026-03-01
endTime = 2026-03-20
page = 1
size = 10
200 OK 128ms
{
"code": 200,
"data": {
"total": 1230,
"list": [
{ "id": 1001, "orderNo": "ORD20260315001", "status": 2, ... },
{ "id": 998, "orderNo": "ORD20260314023", "status": 2, ... },
// ... 8 more
]
}
}

再测一个 — 所有条件都不填,应该返回全部数据:

GET
/api/orders?page=1&size=10
200 OK 89ms
{ "code": 200, "data": { "total": 58432, "list": [...] } }
全部通过! 有条件时精确过滤,无条件时返回全量,动态SQL工作正常。

1.5 分页实现

分页有两种做法,小张选择了PageHelper(MyBatis分页插件):

OrderService.java — order-service
OrderMapper.xml
OrderService.java
OrderController.java
📂 mapper
📂 service
📄 OrderService.java
📂 controller
📄 OrderController.java
1public PageResult<OrderVO> searchOrders(OrderQueryDTO query) {
2 // PageHelper自动拦截下一条SQL,加LIMIT
3 PageHelper.startPage(query.getPage(), query.getSize());
4 List<OrderVO> list = orderMapper.searchOrders(query);
5 PageInfo<OrderVO> info = new PageInfo<>(list);
6 return new PageResult<>(info.getTotal(), info.getList());
7}
PageHelper 原理:MyBatis拦截器,在SQL执行前自动拼接 LIMIT 语句,执行后自动查询 COUNT(*)

也可以不依赖插件,手写分页:

DataGrip — 手动分页验证
-- 第1页:LIMIT 0, 10 SELECT o.*, c.name as customer_name FROM t_order o LEFT JOIN t_customer c ON o.customer_id = c.id ORDER BY o.create_time DESC LIMIT 0, 10; -- 第2页:LIMIT 10, 10 SELECT ... LIMIT 10, 10; -- 查总数(分页必需) SELECT COUNT(*) FROM t_order o;
分页有两个常见坑:1) 深分页问题 — LIMIT 500000, 10 MySQL会先扫描50万行再丢弃,非常慢。解决方案:用游标分页(WHERE id > last_id)2) 总数查询慢 — COUNT(*) 在InnoDB里要扫全表,数据量大时也很慢。可以考虑缓存总数或估算。
场景 2
📊 大数据量导出 Excel

场景:运营要导出50万条订单数据

2.1 收到需求
💬 运营支持群 (6)
—— 2026-04-02 14:30 ——
运营小王
帮我导出所有订单数据,大概50万条,要做月度分析
小张(后端)
50万条?!一次查出来内存会爆的,我得用分批查询+流式写入
运营小王
啊?那得多慢啊...能快点出吗?
小张(后端)
我先做同步版本,快的话2分钟能下完。如果还是慢,再改成异步任务

2.2 第一版 — 直接全查(会OOM)

新手第一反应——查全表,写Excel:

OrderExportService.java — ❌ 危险代码
OrderExportService.java
📂 service
📄 OrderExportService.java
1public void exportOrders(HttpServletResponse response) {
2 // ❌ 50万条全加载到内存!
3 List<Order> all = orderMapper.selectAll();
4 // 每条Order对象约2KB,50万条 ≈ 1GB内存
5 // ... 写入Excel ...
6}

上线后运营一跑,结果——

java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.ArrayList.grow(ArrayList.java:261) at java.util.ArrayList.add(ArrayList.java:487) at com.company.order.mapper.OrderMapper.selectAll(OrderMapper.java:45) at com.company.order.service.OrderExportService.exportOrders(OrderExportService.java:3) ## JVM堆内存默认256MB,50万条数据远超这个限制
OOM了! 50万条Order对象 + Excel对象同时存在内存中,堆直接爆了。这是大数据量处理最常见的坑。

看一下内存分析:

# 每条Order对象大约占多少内存? # - Order对象本身: ~200 bytes # - 内部String字段(orderNo, customerName...): ~800 bytes # - 关联的其他对象: ~1KB # 合计: 每条约2KB # # 50万条 × 2KB = 1GB — 光Order列表就1GB # 再加上POI构建Excel时的内存开销(HSSFWorkbook全量缓存)≈ 2-3GB # 总计需要3-4GB内存,远超JVM默认堆大小 $ java -XX:+PrintGCDetails -jar order-service.jar Heap PSYoungGen total 76288K, used 76280K [99% full!] Full GC (Allocation Failure) — 50万条数据根本放不下
为什么POI这么吃内存?Apache POI的 HSSFWorkbook(xls格式)会把整个Excel模型加载到内存。写10万行就需要约1GB内存。所以50万条直接OOM。EasyExcel用SAX流式模式,逐行写入,内存占用极低。

2.3 正确做法 — 分批查询 + 流式写Excel
OrderExportService.java — ✅ 修复版
OrderExportService.java
OrderController.java
📂 service
📄 OrderExportService.java
📂 controller
📄 OrderController.java
1public void exportOrders(HttpServletResponse response) throws IOException {
2 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
3 response.setHeader("Content-Disposition", "attachment;filename=orders.xlsx");
4
5 // EasyExcel 流式写入,内存中只有一行数据
6 ExcelWriter writer = EasyExcel.write(response.getOutputStream(), OrderExportVO.class).build();
7 WriteSheet sheet = EasyExcel.writerSheet("订单数据").build();
8
9 int pageSize = 5000;
10 int page = 0;
11 while (true) {
12 List<Order> batch = orderMapper.selectPage(page * pageSize, pageSize);
13 if (batch.isEmpty()) break;
14 writer.write(batch.stream().map(this::toExportVO).collect(Collectors.toList()), sheet);
15 page++;
16 }
17 writer.finish();
18}
UTF-8 Java ✓ Memory usage: ~20MB (stable)
关键:EasyExcel 是阿里开源的Excel工具,它采用SAX模式逐行写入,内存中始终只保存一行数据。50万条数据用传统POI需要1GB+内存,EasyExcel只需不到20MB。
每批查5000条,写完立即释放,GC回收。50万条 = 100次循环,每次只占10MB左右。

2.4 改成异步任务(更好的做法)
💬 运营支持群 (6)
—— 2026-04-02 17:00 ——
小张(后端)
50万条导出要跑2分钟,不能让用户等浏览器转圈。我改成异步:用户点"导出"就返回,后台慢慢跑,跑完通知下载
用户点"导出"
接口返回"任务已创建"
后台线程分批查询+写入文件
发通知
用户下载文件
OrderExportController.java — 异步导出
OrderExportController.java
ExportTaskService.java
📂 controller
📄 OrderExportController.java
📂 service
📄 ExportTaskService.java
1@PostMapping("/api/export/orders")
2public Result<String> createExportTask() {
3 // 创建导出任务记录
4 ExportTask task = exportTaskService.createTask();
5 // 异步执行导出
6 @Async
7 exportTaskService.executeExport(task.getId());
8 return Result.success("任务已创建,请在导出记录页下载");
9}

用户在"导出记录"页面查看和下载:

https://admin.company.com/export/records
任务ID 类型 状态 创建时间 操作
EXP20260402001 订单导出 已完成 2026-04-02 17:05 下载
EXP20260402002 订单导出 导出中... 2026-04-02 17:30
异步导出的好处:用户体验好(不用等),服务器压力可控(单线程逐批跑),超时自动失败

异步导出的完整流程 — ExportTaskService:

ExportTaskService.java — 异步执行
ExportTaskService.java
ExportTask.java
📂 service
📄 ExportTaskService.java
📂 entity
📄 ExportTask.java
1@Service
2public class ExportTaskService {
3
4 @Async
5 public void executeExport(Long taskId) {
6 ExportTask task = taskMapper.selectById(taskId);
7 task.setStatus("RUNNING");
8 taskMapper.updateById(task);
9
10 try {
11 String filePath = doExport(task); // 分批查询+流式写Excel
12 task.setStatus("DONE");
13 task.setFilePath(filePath);
14 notifyService.send("导出完成,请下载");
15 } catch (Exception e) {
16 task.setStatus("FAILED");
17 task.setErrorMsg(e.getMessage());
18 }
19 taskMapper.updateById(task);
20 }
21}
导出记录表设计:
DataGrip — export_task 表结构
CREATE TABLE export_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(32) UNIQUE NOT NULL, -- EXP20260402001 type VARCHAR(32), -- 导出类型 status VARCHAR(16), -- RUNNING / DONE / FAILED file_path VARCHAR(256), -- 完成后的文件路径 error_msg TEXT, -- 失败原因 creator_id BIGINT, created_at DATETIME DEFAULT NOW() );
临时文件也要定期清理!用户下载完的导出文件,保留7天后自动删除,避免磁盘撑满
场景 3
🔗 对接第三方支付接口

场景:要对接微信支付,从零开始

3.1 看文档

小张打开微信支付官方文档,仔细阅读API规范:

https://pay.weixin.qq.com/wiki/doc/apiv3/wxpay/pay/transactions

Native下单API

接口地址:POST https://api.mch.weixin.qq.com/v3/pay/transactions/native
必填参数:appid, mchid, description, out_trade_no, amount
签名方式:请求必须使用商户私钥签名 (SHA256-RSA)
回调通知:支付成功后微信会向 notifyUrl 发 POST 通知
签名规则:
1. 构造签名串 = HTTP方法\nURL\n时间戳\n随机串\n请求体\n
2. 用商户私钥对签名串做SHA256withRSA签名
3. Authorization头 = WECHATPAY2-SHA256-RSA2048 mchid="...",nonce_str="...",timestamp="...",serial_no="...",signature="..."

签名串的构造过程详解:

# 签名串格式(每行用\n分隔,最后也要\n) POST\n/v3/pay/transactions/native\n1710936000\na3b2c1d4e5\n{"appid":"wx1234..."} # Java代码构造 String message = String.join("\n", "POST", "/v3/pay/transactions/native", timestamp, nonceStr, requestBody, "" // 末尾空行,别漏! );
签名串最后一行的空 \n 很容易被忽略,但微信文档明确要求。漏掉这一行,签名就会对不上,接口返回"签名错误"。这个问题困扰过无数开发者。

3.2 申请密钥
💬 支付对接群 (4)
—— 2026-04-08 周一 ——
小张(后端)
微信支付要申请商户号和API密钥,我已经提交了资料,等审核
—— 3天后... ——
小张(后端)
密钥下来了!mchId、apiV3Key和证书文件都拿到了,开始写代码

配置文件:

application-prod.yml
wx: pay: appId: wx1234567890 mchId: 1600000001 apiV3Key: your-api-v3-key-here privateKeyPath: /cert/apiclient_key.pem notifyUrl: https://api.company.com/pay/notify
安全提醒:密钥文件 .pem 绝对不能提交到Git!在 .gitignore 里加上 /cert/,密钥由运维在部署时挂载。

3.3 写签名逻辑
WxPaySignUtil.java — payment-service
WxPaySignUtil.java
WxPayService.java
WxPayController.java
📂 util
📄 WxPaySignUtil.java
📂 service
📄 WxPayService.java
📂 controller
📄 WxPayController.java
1public class WxPaySignUtil {
2
3 /** 用商户私钥对请求做SHA256withRSA签名 */
4 public static String createSign(String message, PrivateKey privateKey)
5 throws Exception {
6 Signature sign = Signature.getInstance("SHA256withRSA");
7 sign.initSign(privateKey);
8 sign.update(message.getBytes(StandardCharsets.UTF_8));
9 return Base64.getEncoder().encodeToString(sign.sign());
10 }
11
12 /** 构造签名串 */
13 public static String buildMessage(String method, String url,
14 String timestamp, String nonceStr, String body) {
15 return String.join("\n", method, url, timestamp, nonceStr, body, "");
16 }
17}

3.4 调接口下单

签名写好了,先用Postman测试一下下单接口:

POST
https://api.mch.weixin.qq.com/v3/pay/transactions/native
Headers
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1600000001",nonce_str="a3b2c1",timestamp="1710936000",serial_no="CERT001",signature="Base64EncodedSign..."
Content-Type: application/json
Body (JSON)
{
  "appid": "wx1234567890",
  "mchid": "1600000001",
  "description": "商品订单",
  "out_trade_no": "ORD20260408001",
  "notify_url": "https://api.company.com/pay/notify",
  "amount": { "total": 9900, "currency": "CNY" }
}
200 OK 456ms
{
"code_url": "weixin://wxpay/bizpayurl?pr=abc123def"
}
// code_url 就是二维码内容,前端拿到后渲染成二维码让用户扫码
金额是"分"为单位!9900 = 99元。这个坑很多新手都踩过——微信支付金额单位是分,不是元。

3.5 处理回调通知

用户扫码付款后,微信会主动推送支付结果到notifyUrl:

WxPayController.java — payment-service
WxPaySignUtil.java
WxPayController.java
📂 controller
📄 WxPayController.java
📂 service
📄 WxPayService.java
1@PostMapping("/pay/notify")
2public Map<String,String> payNotify(
3 @RequestBody String body,
4 @RequestHeader("Wechatpay-Signature") String signature,
5 @RequestHeader("Wechatpay-Timestamp") String timestamp,
6 @RequestHeader("Wechatpay-Nonce") String nonce) {
7
8 // 1. 验签 — 确认请求确实来自微信,不是伪造的
9 boolean verified = verifySign(body, signature, timestamp, nonce);
10 if (!verified) {
11 return Map.of("code", "FAIL", "message", "验签失败");
12 }
13
14 // 2. 解密通知内容
15 String decryptData = decrypt(body);
16
17 // 3. 更新订单状态
18 PaymentNotification notification = JSON.parseObject(decryptData, PaymentNotification.class);
19 orderService.updatePayStatus(notification.getOutTradeNo(), "PAID");
20
21 // 4. 返回成功,微信不再重试通知
22 return Map.of("code", "SUCCESS", "message", "成功");
23}
重要:回调通知必须做两件事:1) 验签 — 防止伪造通知,攻击者可能发送假的"支付成功"通知来白嫖商品;2) 幂等处理 — 微信可能重复发通知,必须确保同一笔订单只处理一次。

3.6 测试全流程

端到端测试:从下单到支付成功,整个链路:

https://shop.company.com/order/pay?orderId=1001
微信支付 — 订单号 ORD20260408001
[二维码]
¥99.00
请使用微信扫码支付
✓ 支付成功
用户下单
调微信API
获取code_url
前端渲染二维码
用户扫码付款
微信回调通知
更新订单状态
本地开发时,微信回调到不了localhost。用 ngrok 或 natapp 做内网穿透,把notifyUrl映射到本地

用ngrok做内网穿透的流程:

# 安装ngrok $ brew install ngrok # 启动本地服务(8080端口) $ java -jar order-service.jar Started Application on port 8080 # 开启隧道,把公网地址映射到本地8080 $ ngrok http 8080 Session Status: online Forwarding: https://a1b2c3.ngrok.io -> http://localhost:8080 # 把notifyUrl改成ngrok地址 # notifyUrl: https://a1b2c3.ngrok.io/pay/notify # 微信的回调就能打到本地了!
排错经验:微信支付开发最常见的3个问题:1) 签名错误 — 检查签名串格式,注意最后要加一个空行(\n) 2) 证书路径错误 — 本地开发用绝对路径,生产环境用classpath 3) 回调收不到 — 检查服务器防火墙是否放行了微信IP段。

微信支付回调的时序图:

用户
商户前端
商户后端
微信支付
1. 用户点"支付" → 前端请求商户后端创建订单
2. 商户后端调微信Native下单API → 返回code_url
3. 前端把code_url渲染成二维码 → 用户扫码付款
4. 微信服务器推送支付结果到notifyUrl → 商户后端验签+更新订单
5. 前端轮询订单状态 → 显示"支付成功"
补充:微信支付场景最容易出问题的环节 — 退款流程。退款不是简单的"把钱还回去",而是涉及幂等、部分退款、异步回调等一系列坑。
// 退款流程 — 微信支付最容易出问题的环节
@Service
public class WxRefundService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 申请退款
     * 常见坑:
     * 1. 重复退款 — 同一笔订单退款两次,钱扣两次!必须用 out_refund_no 做幂等
     * 2. 部分退款 — 一笔订单退一部分,剩余金额要正确计算
     * 3. 退款回调 — 退款是异步的,不能只看申请返回,必须等回调确认
     */
    public RefundResult refund(String orderId, Integer refundAmount, String reason) {
        Order order = orderMapper.selectById(orderId);

        // 1. 幂等检查:是否已退过
        if (order.getRefundStatus() != null && order.getRefundStatus().equals("REFUNDED")) {
            return RefundResult.alreadyRefunded(orderId);
        }

        // 2. 金额校验:退款不能超过支付金额
        if (refundAmount > order.getPayAmount()) {
            throw new BusinessException("退款金额不能超过支付金额");
        }

        // 3. 调微信退款API
        WxRefundRequest request = new WxRefundRequest()
            .setOutTradeNo(orderId)                    // 原支付单号
            .setOutRefundNo("RF_" + orderId)           // 退款单号(幂等键!)
            .setTotalAmount(order.getPayAmount())       // 原支付金额(分)
            .setRefundAmount(refundAmount)             // 退款金额(分)
            .setReason(reason);

        WxRefundResponse response = wxPayService.refund(request);

        // 4. 注意:退款是异步的!这里只是"申请成功",真正到账等回调
        if (response.isSuccess()) {
            order.setRefundStatus("REFUNDING");
            orderMapper.updateById(order);
            return RefundResult.processing(orderId);
        }

        throw new BusinessException("退款申请失败: " + response.getMsg());
    }

    // 退款回调 — 微信异步通知退款结果
    @PostMapping("/callback/refund")
    public String handleRefundCallback(@RequestBody String body) {
        WxRefundCallback callback = wxPayService.parseRefundCallback(body);

        Order order = orderMapper.selectById(callback.getOutTradeNo());

        if (callback.isSuccess()) {
            order.setRefundStatus("REFUNDED");
            order.setRefundAmount(callback.getRefundAmount());
        } else {
            order.setRefundStatus("REFUND_FAILED");
            order.setRefundFailReason(callback.getFailReason());
        }

        orderMapper.updateById(order);
        return "SUCCESS";  // 告诉微信别再通知了
    }
}
  
场景 4
⏰ 定时任务开发

场景:每天凌晨3点跑数据统计

4.1 需求
💬 产品需求群 (7)
—— 2026-04-10 11:00 ——
产品小赵
每天凌晨统计前一天的销售数据,生成日报。包括:总销售额、订单数、客单价、各品类占比
小张(后端)
了解,用Quartz定时任务来做。Cron表达式 0 0 3 * * ?,每天凌晨3点执行

4.2 Quartz 配置

小张先写Job类:

DailyStatsJob.java — report-service
DailyStatsJob.java
QuartzConfig.java
DailyStatsService.java
📂 job
📄 DailyStatsJob.java
📂 config
📄 QuartzConfig.java
📂 service
📄 DailyStatsService.java
1@Component
2public class DailyStatsJob implements Job {
3
4 @Autowired
5 private DailyStatsService statsService;
6
7 @Override
8 public void execute(JobExecutionContext context) {
9 LocalDate yesterday = LocalDate.now().minusDays(1);
10 log.info("开始统计日报: {}", yesterday);
11 statsService.generateDailyReport(yesterday);
12 log.info("日报统计完成: {}", yesterday);
13 }
14}

然后配置Quartz的JobDetail和Trigger:

QuartzConfig.java — report-service
DailyStatsJob.java
QuartzConfig.java
📂 job
📄 DailyStatsJob.java
📂 config
📄 QuartzConfig.java
1@Configuration
2public class QuartzConfig {
3
4 @Bean
5 public JobDetail dailyStatsJobDetail() {
6 return JobBuilder.newJob(DailyStatsJob.class)
7 .withIdentity("dailyStats", "report")
8 .storeDurably()
9 .build();
10 }
11
12 @Bean
13 public Trigger dailyStatsTrigger() {
14 return TriggerBuilder.newTrigger()
15 .forJob(dailyStatsJobDetail())
16 .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 * * ?"))
17 .build();
18 }
19}
Cron表达式解读:0 0 3 * * ? = 秒0 分0 时3 每天 每月 任意星期 → 每天凌晨3:00:00执行。
常见误区:*? 不能同时出现在"日"和"周"位,其中一个必须用 ?

4.3 测试 — 手动触发

不能等到凌晨3点才测试!小张写了个手动触发的接口:

$ curl -X POST http://localhost:8080/api/job/trigger/dailyStats { "code": 200, "message": "任务已触发" } # 后台日志输出: 2026-04-10 14:32:01 INFO [scheduling-1] DailyStatsJob - 开始统计日报: 2026-04-09 2026-04-10 14:32:03 INFO [scheduling-1] DailyStatsService - 查询销售数据... 2026-04-10 14:32:04 INFO [scheduling-1] DailyStatsService - 写入日报记录 2026-04-10 14:32:04 INFO [scheduling-1] DailyStatsJob - 日报统计完成: 2026-04-09

4.4 看执行结果

小张打开DataGrip,查看生成的日报数据:

DataGrip — report_db / daily_report
SELECT * FROM daily_report ORDER BY report_date DESC LIMIT 5;
id
report_date
total_sales
order_count
avg_order_amount
created_at
31
2026-04-09
¥128,450.00
1,285
¥99.96
2026-04-10 03:00
30
2026-04-08
¥135,200.00
1,352
¥99.88
2026-04-09 03:00
29
2026-04-07
¥112,800.00
1,128
¥100.00
2026-04-08 03:00

4.5 失败重试 — 真实事故
💬 紧急问题群 (5)
—— 2026-04-12 08:30 ——
小张(后端)
凌晨3点的任务执行失败了!看日志...
2026-04-12 03:00:01 ERROR [scheduling-1] DailyStatsJob - 日报统计失败 org.springframework.dao.QueryTimeoutException: at com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1280) # 原因:统计SQL查询超时,前一天数据量暴增(促销活动)

小张加了两个改进:1) 查询超时优化 2) 失败自动重试

DailyStatsJob.java — 加重试
DailyStatsJob.java
📂 job
📄 DailyStatsJob.java
1@Component
2public class DailyStatsJob implements Job {
3
4 @Override
5 public void execute(JobExecutionContext context) {
6 LocalDate yesterday = LocalDate.now().minusDays(1);
7 int maxRetries = 3;
8 for (int i = 0; i < maxRetries; i++) {
9 try {
10 statsService.generateDailyReport(yesterday);
11 return; // 成功则退出
12 } catch (Exception e) {
13 log.error("第{}次重试失败: {}", i + 1, e.getMessage());
14 if (i == maxRetries - 1) {
15 // 最后一次也失败,发告警
16 alertService.sendAlert("日报统计失败: " + yesterday);
17 }
18 }
19 }
20 }
21}
生产经验:定时任务必须有监控:1) 失败重试机制 2) 执行超时报警 3) 任务执行记录表(每次执行写一条记录,记录开始时间、结束时间、状态、错误信息)4) 关键任务失败要发钉钉/企微告警。
Quartz也支持配置misfire策略:错过执行时是立即补跑还是等下次

定时任务监控表设计:

DataGrip — job_execution_log 表结构
CREATE TABLE job_execution_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_name VARCHAR(64), -- dailyStats job_group VARCHAR(64), -- report status VARCHAR(16), -- SUCCESS / FAILED start_time DATETIME, end_time DATETIME, duration_ms INT, -- 执行耗时(毫秒) error_msg TEXT, -- 失败原因 trigger_type VARCHAR(16) -- CRON / MANUAL );

小张打开DataGrip查看执行记录:

DataGrip — job_execution_log
job_name
status
start_time
duration_ms
error_msg
dailyStats
SUCCESS
2026-04-11 03:00
2,340
dailyStats
FAILED
2026-04-12 03:00
QueryTimeoutException
dailyStats
SUCCESS
2026-04-12 03:02
1,890
最佳实践:1) 所有定时任务必须写执行日志 2) 关键任务失败要即时告警 3) 执行时间超过阈值也要告警 4) 临时修改Cron表达式走配置中心,不要改代码 5) 并发控制:同一任务不能同时执行两次(用Redis分布式锁)。
场景 5
📁 大文件分片上传

场景:用户要上传500MB的视频文件

5.1 需求
💬 培训平台开发群 (5)
—— 2026-04-15 10:00 ——
产品小赵
用户要上传培训视频,最大500MB。普通的文件上传方式行不通吧?
小张(后端)
500MB用传统单次上传会超时。必须分片上传:前端把文件切成5MB一片,逐个上传,最后后端合并
传统上传的问题:1) 500MB一个请求,nginx默认1MB限制 2) 网络波动导致整个上传失败,从头再来 3) 无法显示进度
分片上传的优势:1) 每片5MB,不会超时 2) 某片失败只重传该片 3) 可以显示进度 4) 支持断点续传

5.2 前端分片逻辑
Visual Studio Code — training-platform
UploadVideo.vue
uploadApi.js
EXPLORER
📂 src/
📂 components/
📄 UploadVideo.vue
📂 api/
📄 uploadApi.js
1async function uploadFile(file) {
2 const chunkSize = 5 * 1024 * 1024; // 5MB一片
3 const totalChunks = Math.ceil(file.size / chunkSize);
4 const fileId = generateFileId(file); // 用文件内容hash做唯一ID
5
6 for (let i = 0; i < totalChunks; i++) {
7 const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
8 const formData = new FormData();
9 formData.append('file', chunk);
10 formData.append('chunkIndex', i);
11 formData.append('totalChunks', totalChunks);
12 formData.append('fileId', fileId);
13
14 await axios.post('/api/upload/chunk', formData, {
15 onUploadProgress: (e) => updateProgress(i, totalChunks, e.loaded / e.total)
16 });
17 }
18
19 // 所有分片上传完毕,通知后端合并
20 await axios.post('/api/upload/merge', {
21 fileId, totalChunks, fileName: file.name
22 });
23}
Vue UTF-8 Ln 6, Col 20
file.slice():这是HTML5 File API的方法,可以从大文件中"切出"指定范围的数据,不会把整个文件加载到内存。

5.3 后端接收分片
UploadController.java — training-service
UploadController.java
UploadService.java
📂 controller
📄 UploadController.java
📂 service
📄 UploadService.java
1@RestController
2@RequestMapping("/api/upload")
3public class UploadController {
4
5 // 接收单个分片
6 @PostMapping("/chunk")
7 public Result<?> uploadChunk(
8 @RequestParam MultipartFile file,
9 @RequestParam int chunkIndex,
10 @RequestParam String fileId) {
11 Path chunkDir = Paths.get(tempDir, fileId);
12 Files.createDirectories(chunkDir);
13 file.transferTo(chunkDir.resolve(String.valueOf(chunkIndex)));
14 return Result.success();
15 }
16
17 // 合并所有分片
18 @PostMapping("/merge")
19 public Result<String> mergeChunks(@RequestBody MergeRequest req) {
20 Path chunkDir = Paths.get(tempDir, req.getFileId());
21 Path targetFile = Paths.get(uploadDir, req.getFileName());
22 try (OutputStream out = Files.newOutputStream(targetFile)) {
23 for (int i = 0; i < req.getTotalChunks(); i++) {
24 Files.copy(chunkDir.resolve(String.valueOf(i)), out);
25 }
26 }
27 // 合并完清理临时分片
28 FileUtils.deleteDirectory(chunkDir.toFile());
29 return Result.success(targetFile.toString());
30 }
31}
存储策略:分片先存临时目录 /tmp/{fileId}/,合并后删除临时文件。fileId用文件内容的MD5/SHA256,这样同一文件不会重复上传。

临时目录结构示意:

# 上传中的临时目录 $ ls -la /tmp/upload/abc123def/ -rw-r--r-- 5MB 0 (分片0) -rw-r--r-- 5MB 1 (分片1) -rw-r--r-- 5MB 2 (分片2) ... -rw-r--r-- 3MB 99 (最后一片可能不足5MB) # 合并后的最终文件 $ ls -la /data/upload/公司年度培训录播.mp4 -rw-r--r-- 498MB 公司年度培训录播.mp4 # 合并完临时目录已被删除 $ ls /tmp/upload/abc123def/ No such file or directory
合并逻辑很简单:按分片序号0→99顺序读取,写入目标文件。因为序号就是文件名,天然有序,合并速度非常快。500MB文件合并大约需要2-3秒。

5.4 上传进度展示
https://training.company.com/admin/video/upload
上传培训视频
公司年度培训录播.mp4 365MB / 500MB
73%
分片 73/100 已上传 预计剩余: 38秒
分片大小: 5MB · 已上传: 73片 · 速度: 3.5MB/s

5.5 断点续传
💬 测试反馈群 (4)
—— 2026-04-17 15:20 ——
测试小陈
上传到300MB的时候网络断了,重新上传又从头开始,体验太差了。能续传吗?
小张(后端)
可以!加一个"检查已上传分片"的接口,前端上传前先查哪些片已经有了,跳过它们

后端加一个检查接口:

GET
/api/upload/chunks?fileId=abc123
200 OK 12ms
{
"uploadedChunks": [0, 1, 2, 3, ... 59],
"totalChunks": 100
}
// 已上传60片,前端从第61片开始上传
UploadController.java — 断点续传
UploadController.java
📂 controller
📄 UploadController.java
1// 查询已上传的分片索引
2@GetMapping("/chunks")
3public Result<ChunkInfo> getUploadedChunks(@RequestParam String fileId) {
4 Path chunkDir = Paths.get(tempDir, fileId);
5 List<Integer> uploaded = new ArrayList<>();
6 if (Files.exists(chunkDir)) {
7 Files.list(chunkDir)
8 .map(p -> Integer.parseInt(p.getFileName().toString()))
9 .forEach(uploaded::add);
10 }
11 return Result.success(new ChunkInfo(uploaded, uploaded.size()));
12}

前端上传前的逻辑改为:

VS Code — UploadVideo.vue
UploadVideo.vue
1async function uploadFile(file) {
2 const fileId = generateFileId(file);
3
4 // 先查哪些片已上传,跳过它们
5 const { uploadedChunks } = await axios.get(
6 `/api/upload/chunks?fileId=${fileId}`
7 );
8 const skip = new Set(uploadedChunks);
9
10 for (let i = 0; i < totalChunks; i++) {
11 if (skip.has(i)) continue; // 已上传,跳过
12 // ... 上传该片
13 }
14}
断点续传完成! 用户网络断了再上传,直接从上次断开的地方继续,不用从头开始。
生产注意:临时分片文件需要定期清理,否则磁盘会被撑满。可以加一个定时任务,每天清理3天前的临时目录。

清理脚本示例:

# 清理3天前的临时分片(合并时没删干净的) $ find /tmp/upload -type d -mtime +3 -exec rm -rf {} + Deleted: /tmp/upload/expired_file_abc Deleted: /tmp/upload/expired_file_def Freed: 2.3GB disk space

分片上传完整流程总结:

1. 选择文件
2. 生成fileId
3. 检查已上传分片
4. 逐片上传(跳过已有的)
5. 通知后端合并
6. 返回文件URL
分片上传就像搬家:你不可能一次把整个房子搬走,而是把家具拆成零件(分片),逐件搬运(上传),到新家再组装(合并)。如果搬了一半下雨了(网络断了),下次从没搬的那件继续(断点续传),不用从头开始。
补充:断点续传的关键是后端提供一个"查询已上传分片"的接口,前端上传前先查,跳过已有的分片。
// 断点续传:查询已上传的分片列表
@GetMapping("/chunks")
public R<List<Integer>> getUploadedChunks(
        @RequestParam String fileHash,
        @RequestParam int totalChunks) {

    // 根据文件hash查询已上传的分片号
    List<Integer> uploadedChunks = uploadService.getUploadedChunks(fileHash);

    // 前端拿到已上传列表后,只上传缺失的分片
    return R.ok(uploadedChunks);
}

// UploadService 实现
public List<Integer> getUploadedChunks(String fileHash) {
    // 方案1:查数据库
    // List<ChunkRecord> records = chunkMapper.selectByFileHash(fileHash);
    // return records.stream().map(ChunkRecord::getChunkNumber).toList();

    // 方案2:查临时目录(分片先存临时目录,合并后删除)
    String tempDir = "/tmp/upload/" + fileHash;
    File dir = new File(tempDir);
    if (!dir.exists()) return Collections.emptyList();

    return Arrays.stream(dir.listFiles())
        .map(f -> Integer.parseInt(f.getName().replace("chunk_", "")))
        .sorted()
        .toList();
}
  

📋 五大场景总结
场景 核心问题 关键技术 最大教训
多条件查询 10个可选条件的SQL拼接 MyBatis <where>+<if>, 组合索引 不能硬编码WHERE,必须动态SQL
大数据量导出 50万条数据OOM 分批查询+EasyExcel流式写入 一次查全表必OOM,分批+流式才是正解
第三方支付 签名、验签、回调处理 SHA256withRSA签名, 回调验签+幂等 回调必须验签,否则可被伪造
定时任务 任务失败无人知 Quartz + 重试 + 告警 定时任务必须有监控和告警
分片上传 500MB文件上传超时 前端分片 + 后端合并 + 断点续传 大文件必须分片,否则超时+无法续传
这些场景不是面试八股,是每个后端开发者都会遇到的真实问题。记住每个场景的"反面教材"和"正确做法"的对比——很多线上事故就是从"反面教材"开始的。

每个场景的"犯错 → 发现 → 修复"过程:

❌ 常见错误
• 条件写死在WHERE里 → null报错
• 50万条一次查完 → OOM
• 不验签就处理回调 → 被伪造通知
• 定时任务失败没人知道 → 数据缺失
• 大文件一次上传 → 超时+无法续传
✓ 正确做法
• MyBatis <where>+<if> 动态SQL
• 分批查询 + 流式写Excel
• SHA256-RSA签名 + 回调验签
• 重试机制 + 告警通知
• 前端分片 + 后端合并 + 断点续传