下单流程全解析:从点击到支付的完整技术拆解
下面把这个流程展开说明每一层具体做什么、传什么数据、代码怎么写以及几个容易出问题的地方。示例使用 KotlinAndroid其他平台思路相同。一、完整时序数据在各层之间怎么流动用户 View(界面) ViewModel UseCase Repository 服务端 │ 点击提交 │ │ │ │ │ │───────────▶│ │ │ │ │ │ │ submit() │ │ │ │ │ │──────────────▶│ │ │ │ │ │ 状态:loading │ │ │ │ │ │◀──────────────│ │ │ │ │ 按钮变灰 │ │ invoke(购物车,地址) │ │ │ │ │───────────────▶│ │ │ │ │ │ │ 1.校验地址/起送价 │ │ │ │ │ │ 2.预估价格 │ │ │ │ │ │ createOrder() │ │ │ │ │ │────────────────▶│ POST /orders │ │ │ │ │ │───────────────▶│ │ │ │ │ │ │ 校验库存、 │ │ │ │ │ │ 重算价格、 │ │ │ │ │ 订单数据 │ 创建订单 │ │ │ │ │◀───────────────│ │ │ │ │ │ 写入本地数据库 │ │ │ │ │ Order │ │ │ │ │ │◀────────────────│ │ │ │ │ Result(成功) │ │ │ │ │ │◀───────────────│ │ │ │ │ 事件:跳转支付 │ │ │ │ │ │◀──────────────│ │ │ │ │ 进入支付页 │ │ │ │ │每层的输入和输出层输入输出关心的问题View用户点击调用 ViewModel按钮、loading、弹窗长什么样ViewModel用户操作页面状态 一次性事件当前页面处于什么状态UseCase购物车、地址订单 或 具体错误业务规则是否满足Repository下单参数订单领域模型数据从网络来还是本地来API / 数据库请求 DTO响应 DTO接口格式、表结构二、各层代码0. 领域模型各层共用的“业务语言”// 金额统一用“分”Long不要用 Double避免 0.1 0.2 ≠ 0.3 的精度问题dataclassCartItem(valdishId:String,valname:String,valprice:Long,valcount:Int)dataclassCart(valshopId:String,valitems:ListCartItem,valcouponId:String?)dataclassAddress(valid:String,vallat:Double,vallng:Double,valdetail:String)dataclassOrder(valid:String,valpayAmount:Long,// 服务端算出的实付金额valstatus:OrderStatus)enumclassOrderStatus{WAIT_PAY,PAID,CANCELLED}1. 数据层Repository接口定义在领域层实现放在数据层依赖倒置领域层不依赖具体的网络库和数据库。// 领域层只定义“我需要什么能力” interfaceOrderRepository{suspendfuncreateOrder(cart:Cart,address:Address,requestId:String):Order}// 数据层网络接口Retrofit interfaceOrderApi{POST(orders)suspendfuncreateOrder(Header(Idempotency-Key)requestId:String,// 防重复下单后面详细讲Bodybody:CreateOrderRequest):CreateOrderResponse}// 网络传输用的 DTO字段名跟接口文档走dataclassCreateOrderRequest(valshop_id:String,valaddress_id:String,valitems:ListItemDto,valcoupon_id:String?)dataclassItemDto(valdish_id:String,valcount:Int)dataclassCreateOrderResponse(valorder_id:String,valpay_amount:Long,valstatus:String)// 数据层Repository 实现 classOrderRepositoryImpl(privatevalapi:OrderApi,privatevaldao:OrderDao// Room 数据库):OrderRepository{overridesuspendfuncreateOrder(cart:Cart,address:Address,requestId:String):Order{// 1. 领域模型 → 请求 DTOvalrequestCreateOrderRequest(shop_idcart.shopId,address_idaddress.id,itemscart.items.map{ItemDto(it.dishId,it.count)},// 只传 ID 和数量不传价格coupon_idcart.couponId)// 2. 调用网络valrespapi.createOrder(requestId,request)// 3. 响应 DTO → 领域模型valorderOrder(idresp.order_id,payAmountresp.pay_amount,statusOrderStatus.valueOf(resp.status))// 4. 写入本地数据库订单列表页、支付页可以直接读断网也能看dao.insert(order.toEntity())returnorder}}为什么要区分 DTO 和领域模型后端改了字段名如pay_amount→actual_amount只需要改数据层的转换代码上面的 UseCase、ViewModel、界面都不受影响。2. 领域层PlaceOrderUseCase// 业务错误要具体界面才能给出准确提示sealedclassPlaceOrderError:Exception(){objectNoAddress:PlaceOrderError()objectOutOfRange:PlaceOrderError()// 超出配送范围dataclassBelowMinimum(valdiff:Long):PlaceOrderError()// 差多少起送objectEmptyCart:PlaceOrderError()}classPlaceOrderUseCase(privatevalorderRepo:OrderRepository,privatevalshopRepo:ShopRepository,privatevalpriceCalculator:PriceCalculator){suspendoperatorfuninvoke(cart:Cart,address:Address?,requestId:String):ResultOrderrunCatching{// ① 校验if(cart.items.isEmpty())throwPlaceOrderError.EmptyCartif(addressnull)throwPlaceOrderError.NoAddressvalshopshopRepo.getShop(cart.shopId)if(!shop.canDeliverTo(address))throwPlaceOrderError.OutOfRange// ② 计算商品总价判断起送价valgoodsTotalpriceCalculator.goodsTotal(cart)if(goodsTotalshop.minOrderAmount){throwPlaceOrderError.BelowMinimum(shop.minOrderAmount-goodsTotal)}// ③ 真正下单orderRepo.createOrder(cart,address,requestId)}}这一层完全不涉及Android、界面、网络库可以直接写单元测试Testfun低于起送价时返回差额()runTest{valuseCasePlaceOrderUseCase(fakeOrderRepo,fakeShopRepo(minOrder2000),PriceCalculator())valresultuseCase(cartOf(price1500),someAddress,req-1)assertEquals(PlaceOrderError.BelowMinimum(500),result.exceptionOrNull())}3. 表现层ViewModel// 持续状态界面要一直显示的dataclassConfirmUiState(valsubmitting:Booleanfalse,valestimatedPrice:Long0,valerrorMsg:String?null)// 一次性事件只执行一次的跳转、ToastsealedclassConfirmEvent{dataclassGoToPay(valorderId:String,valamount:Long):ConfirmEvent()}classConfirmOrderViewModel(privatevalplaceOrder:PlaceOrderUseCase,privatevalcart:Cart,privatevaraddress:Address?):ViewModel(){privateval_stateMutableStateFlow(ConfirmUiState())valstate:StateFlowConfirmUiState_stateprivateval_eventsChannelConfirmEvent()valevents_events.receiveAsFlow()// 进入确认页时生成一次重试时复用同一个 IDprivatevalrequestIdUUID.randomUUID().toString()funsubmit(){if(_state.value.submitting)return// 防止连点viewModelScope.launch{_state.update{it.copy(submittingtrue,errorMsgnull)}placeOrder(cart,address,requestId).onSuccess{order-_state.update{it.copy(submittingfalse)}_events.send(ConfirmEvent.GoToPay(order.id,order.payAmount))}.onFailure{e-_state.update{it.copy(submittingfalse,errorMsge.toMessage())}}}}privatefunThrowable.toMessage()when(this){PlaceOrderError.NoAddress-请选择收货地址PlaceOrderError.OutOfRange-该地址超出配送范围isPlaceOrderError.BelowMinimum-还差${diff/100.0}元起送isIOException-网络不太好请重试else-下单失败请稍后再试}}4. 表现层界面ComposeComposablefunConfirmOrderScreen(vm:ConfirmOrderViewModel,navController:NavController){valstatebyvm.state.collectAsState()// 监听一次性事件LaunchedEffect(Unit){vm.events.collect{event-when(event){isConfirmEvent.GoToPay-navController.navigate(pay/${event.orderId})}}}Column{Text(预计支付 ¥${state.estimatedPrice/100.0})state.errorMsg?.let{Text(it,colorColor.Red)}Button(onClick{vm.submit()},enabled!state.submitting// 提交中按钮不可点){if(state.submitting)CircularProgressIndicator(Modifier.size(16.dp))elseText(提交订单)}}}界面里没有任何业务判断只是“状态是什么就显示什么”。三、几个关键细节面试和实际开发都常问细节 1客户端算的价格只是“预估”以服务端为准前面架构概述里说过“价格计算放在服务端客户端不可信”这里 UseCase 又在算价格并不矛盾客户端计算为了体验 - 确认页即时显示“预计 ¥28.5” - 判断是否达到起送价提前提示用户 → 可能被篡改也可能因为优惠过期而不准 服务端计算为了正确 - 根据商品 ID 从数据库查真实价格 - 重新校验优惠券、库存、活动 → 最终支付金额以服务端返回的 payAmount 为准所以请求里只传商品 ID 和数量不传价格。如果服务端价格和预估不同例如优惠券刚过期应该提示用户“价格有变化”再让用户确认。细节 2防止重复下单两道防线第一道客户端 submitting true 时按钮禁用submit() 直接 return → 防止用户手抖连点 第二道服务端幂等更重要 请求头带 Idempotency-Key同一次下单用同一个 requestId 服务端记录requestId → orderId 同一个 requestId 第二次请求时直接返回已创建的订单不再新建细节 3网络超时 ≠ 下单失败这和之前讲的丢包问题直接相关情况 A请求包丢了 → 服务端没收到订单没创建 情况 B响应包丢了 → 服务端已创建订单只是客户端不知道 客户端看到的都是“超时”无法区分如果超时后生成新的 requestId 重试情况 B 就会产生两个订单。正确做法超时后 → 提示“网络不好请重试” 用户重试 → 使用同一个 requestId 服务端 情况 A → 正常创建订单 情况 B → 发现 requestId 已存在返回原订单 ✓这也是上面代码中requestId放在 ViewModel 属性里、而不是每次submit()时生成的原因。细节 4跳转用“一次性事件”不要放在状态里错误做法state 里放 navigateToPay true → 屏幕旋转后界面重建重新读取状态又跳转一次 正确做法用 Channel 发送事件只被消费一次细节 5为什么下单成功后写入本地数据库1. 支付页、订单列表直接读数据库不用再请求一次 2. 断网时也能查看“我刚下的订单” 3. 后续订单状态变化推送或轮询更新数据库 所有观察数据库的页面会自动刷新四、服务端收到 POST /orders 后做了什么简述1. 网关校验登录 token、限流 2. 幂等检查requestId 是否已处理过Redis 3. 校验店铺是否营业、地址是否在配送范围 4. 查询真实价格、校验优惠券、锁定库存 5. 数据库事务中创建订单状态 待支付 6. 设置超时任务15 分钟未支付自动取消、释放库存 7. 返回订单 ID 和实付金额五、分层带来的实际好处变化需要改哪里其他层确认页改版只改 View不动起送规则调整只改 UseCase不动后端接口字段改名只改 DTO 转换不动Retrofit 换成别的网络库只改数据层不动写单元测试UseCase 用假的 Repository 即可测试不需要真机和网络总结1. View只显示状态、转发点击 2. ViewModel管理页面状态loading、错误和一次性事件跳转 3. UseCase业务校验地址、配送范围、起送价不依赖任何框架 4. Repository模型转换、调用接口、写入本地数据库 关键细节 - 客户端价格只是预估最终以服务端为准请求中不传价格 - 防重复下单按钮禁用 服务端幂等键 - 超时不代表失败重试必须复用同一个 requestId - 跳转用一次性事件避免界面重建后重复跳转一句话分层让“下单”这件事中界面、业务规则、数据来源各管各的而幂等和超时处理决定了这个下单按钮在弱网下是否可靠。

相关新闻

MuPDF PDFPage 类型完全解析:JavaScript API 中的页面加载、批注、编辑、去标识化与渲染实战

MuPDF PDFPage 类型完全解析:JavaScript API 中的页面加载、批注、编辑、去标识化与渲染实战

图形学图像处理 【免费下载链接】mupdf mupdf mirror 项目地址: https://gitcode.com/gh_mirrors/mu/mupdf 点击查看 免费下载 本文基于 MuPDF 官方参考文档 PDFPage.rst 展开,系统讲解 MuPDF JavaScript API 中 PDFPage 类型的全部静态常量与实例方法&…

2026/10/14 0:41:54 阅读更多 →
马尾辫技术实现原理与常见应用场景解析

马尾辫技术实现原理与常见应用场景解析

我无法根据当前输入生成符合要求的博文。原因如下:项目标题“ponytail”是一个英文单词,直译为“马尾辫”,属于常见发型术语,但未提供任何具体项目背景、技术指向、应用场景或领域归属(如时尚造型教程、3D建模中的头发…

2026/10/14 0:41:54 阅读更多 →
室内监控玩手机检测数据集:4974张实拍图+VOC/YOLO/JSON三格式

室内监控玩手机检测数据集:4974张实拍图+VOC/YOLO/JSON三格式

简介:本资源是面向计算机视觉算法工程师、AI项目开发者及高校科研学生的室内监控场景玩手机行为检测专用数据集,适用于岗位分心监测、智能考勤、课堂纪律分析等实际落地场景。数据集共4974张高清监控抓拍图像,当前压缩包含1375张JPG原图、137…

2026/10/14 0:41:54 阅读更多 →

最新新闻

ROS安装完整教程:环境配置、源管理与依赖处理避坑指南

ROS安装完整教程:环境配置、源管理与依赖处理避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 1:25:08 阅读更多 →
STM32寄存器白话手册:从GPIO到中断的底层配置实战

STM32寄存器白话手册:从GPIO到中断的底层配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 1:25:07 阅读更多 →
WFU_PickAtlas实操指南:从脑图谱生成ROI掩膜并接入SPM流程

WFU_PickAtlas实操指南:从脑图谱生成ROI掩膜并接入SPM流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 1:25:07 阅读更多 →
基于数据挖掘的城市空气质量预测系统设计与实现

基于数据挖掘的城市空气质量预测系统设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 1:25:07 阅读更多 →
VISA控制仪器例程:从SCPI到Python产线级实现

VISA控制仪器例程:从SCPI到Python产线级实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 1:25:07 阅读更多 →
Nmap端口扫描实战:从协议原理到企业级资产测绘

Nmap端口扫描实战:从协议原理到企业级资产测绘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 1:24:07 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →