天下事在局外呐喊议论,总是无益,必须躬身入局,挺膺负责,方有成事之可冀。
业务场景
已知有一个 聚合支付 SDK
—— 可以把它理解成,封装了支付宝、微信等支付渠道 SDK,提供统一的完成支付和结果通知接口。
当前业务上新增支付需求,需要把聚合支付 SDK 集成到当前的项目里,从而赋予项目内业务模块以支付的能力,例如集成到运动课程模块,就可以支持下单购买专业运动课程;集成到睡眠模块,就可以支持购买专业睡眠报告分析等等。
设计目标
这是在项目里首次引入支付能力,希望不仅可以实现当前的产品需求(即在运动课程里添加购买功能),还能够支撑未来更多模块使用支付功能。
设计目标梳理如下:
- 安全性:由于涉及用户财产,设计支付相关模块时首当其冲的就是安全性。
- 通用性:应用类的任意模块,都可以使用封装后的支付能力。
- 可测试性:能够自由切换底层实现,例如,APP开发过程中提供 mock 的下单、支付接口,以便在启动前后端联调前,就可以验证前端 UI 逻辑。
- 伸缩性:未来聚合支付 SDK 进行升级时,对已接入的业务不产生影响。
- 隐藏实现细节:上层业务无需关心底层支付流程细节。
- 与 Jetpack 无缝集成:采用
StateFlow
模式,利用Hilt
管理对象生命周期。
设计思路
将上述设计目标分门别类后,思考对应的技术选型。
目标 | 技术选型 | 说明 |
---|---|---|
安全性 | 独立 module + 网络传输加密 | 将支付模块作为独立模块,未来可以抽出 aar 集成,也便于混淆控制。下单、查询订单状态等接口,进行二次加密从而提升安全等级 |
通用性,伸缩性,隐藏实现细节 | Activity-ViewModel-Repository-DataSource 的分层设计 | PaySdkClient(聚合支付 SDK)作为 DataSource 对象,只对 Repository 可见 |
可测试性 | 将 PayRepository 声明为接口,并提供 PayRepositoryImpl 、FakePayRepository 两个实现 | APP 开发阶段使用 FakePayRepository ,联调阶段切换为 PayRepositoryImpl |
与 Jetpack 无缝集成 | Hilt + Flow | 使用 Hilt 进行单例作用域管理,使用 Flow 实现支付状态优雅流转 |
架构图
Domain
层可选,当业务场景复杂度高、有复用的需求时,会将其封装出Domain
层的UseCase
。ViewModel
既可以引用 UseCase,也可以引用Repository
。- 在
Repository
层的每个模块,提供了真实实现和 Fake 实现,后者可用于开发阶段自测、自动化测试。 - 层与层之间单向依赖,通过
Hilt
进行实例管理和注入。
时序图
- 两层订单号设计
- 业务订单号:由业务(例如健身课程)后台生成,是业务系统内标识,描述用户买了哪门课、多少钱等。
- 支付交易号:由支付公司生成,在支付渠道内唯一定位这笔支付。
- 两层订单号要做绑定关系,方便对账查账。
- 支付后台 ---支付结果通知--> 业务后台
- 回调:支付后台在用户完成支付后,会向商户预先配置的回调地址(notify URL)发送一条 HTTP POST 请求,里面包含订单号、支付结果、签名等信息。
- 校验:健身课程后台收到后需要做校验(验签、防重放)并更新订单状态。
- 重试:如果商户后台返回失败或超时,支付后台会按策略重试几次(指数退避、定时重发等)。
- 业务后台 ---订单最新状态--> 前端APP页面
- 常见做法(轮询):APP 拿到支付 SDK 支付结果后,立即查询一次健身课程后台。为了等到最终一致性,会做2~3次短时间轮询,每次间隔数秒。
- 推送通知:后台通过移动推送告知 APP 订单状态变更,不保证实时与可靠,一般用作辅助手段。
- 长轮询:前端发起长连接,等后台主动推送订单状态更新。实时性更好,但后台需要具备推送能力,对网络环境要求高。
sequenceDiagram
Note left of APP健身课程页: 1.用户发起支付
APP健身课程页->>APP健身课程页: 发起支付
activate APP健身课程页
Note left of APP健身课程页: 2.创建订单
APP健身课程页->>健身课程后台: 请求订单号
activate 健身课程后台
健身课程后台-->>APP健身课程页: 业务订单号
deactivate 健身课程后台
Note left of APP健身课程页: 3.SDK收银
APP健身课程页->>支付SDK: 拉起收银台
activate 支付SDK
支付SDK->>支付后台: 统一下单接口
activate 支付后台
支付后台-->>支付SDK: 支付交易号
deactivate 支付后台
支付SDK->>支付SDK: 完成支付
Note left of APP健身课程页: 4.返回支付结果
支付SDK -->>APP健身课程页: 支付结果
deactivate 支付SDK
Note left of APP健身课程页: 5.支付后台落账
支付SDK->>支付后台: 发送订单号&支付结果
activate 支付后台
支付后台->>支付后台: 标记该订单已支付
支付后台 -->> 健身课程后台: 通知支付结果(通过提前注册的回调地址)
deactivate 支付后台
Note left of APP健身课程页: 6.确认订单状态
APP健身课程页->>健身课程后台: 查询订单最新状态(同步or轮询)
activate 健身课程后台
健身课程后台-->>APP健身课程页: 已支付订单
deactivate 健身课程后台
deactivate APP健身课程页
Note left of APP健身课程页: 7.刷新界面
APP健身课程页->>APP健身课程页: 刷新页面
开发细节
是分层使用不同的数据类,还是把同一个数据类跨层传输
分层使用不同的数据 Bean
- 关注点分离:数据层-贴近外部协议,领域层-关注业务语义,展示层-关注界面状态。
- 解耦:底层数据类型发生变化时,分层设计可以缓冲这种变化,降低对上层页面逻辑影响。反之亦然。
共用一个 Bean
- 短期省事,但长期可能会让层次之间耦合、边界模糊。
跨层之间 Bean 转换
使用 Mapper 扩展函数
,进行 底层->上层 的转换。
// DTO -> Domain
fun OrderDto.toDomain(): Order {
val status = when (statusCode) {
0 -> OrderStatus.CREATED
1 -> OrderStatus.PAID
else -> OrderStatus.FAILED
}
return Order(
id = orderId,
price = amount,
status = status
)
}
// Domain -> UI
fun Order.toUiModel(): OrderUiModel {
val statusText = when (status) {
OrderStatus.CREATED -> "待支付"
OrderStatus.PAID -> "已支付"
OrderStatus.FAILED -> "支付失败"
}
return OrderUiModel(
displayId = "订单号: $id",
priceLabel = "¥${price / 100.0}", // 转换为金额字符串
statusText = statusText
)
}
在 ViewModel
里面则通过 FLow.map
进行转换。
class OrderViewModel(
private val repository: OrderRepository
) : ViewModel() {
val orderUiState: StateFlow<OrderUiModel?> =
repository.getOrder() // Flow<OrderDto>
.map { dto -> dto.toDomain() }
.map { domain -> domain.toUiModel() }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}