【业务场景架构实战】2. 对聚合支付 SDK 的封装

0 阅读5分钟

天下事在局外呐喊议论,总是无益,必须躬身入局,挺膺负责,方有成事之可冀。

业务场景

已知有一个 聚合支付 SDK —— 可以把它理解成,封装了支付宝、微信等支付渠道 SDK,提供统一的完成支付和结果通知接口。

当前业务上新增支付需求,需要把聚合支付 SDK 集成到当前的项目里,从而赋予项目内业务模块以支付的能力,例如集成到运动课程模块,就可以支持下单购买专业运动课程;集成到睡眠模块,就可以支持购买专业睡眠报告分析等等。

设计目标

这是在项目里首次引入支付能力,希望不仅可以实现当前的产品需求(即在运动课程里添加购买功能),还能够支撑未来更多模块使用支付功能。

设计目标梳理如下:

  • 安全性:由于涉及用户财产,设计支付相关模块时首当其冲的就是安全性。
  • 通用性:应用类的任意模块,都可以使用封装后的支付能力。
  • 可测试性:能够自由切换底层实现,例如,APP开发过程中提供 mock 的下单、支付接口,以便在启动前后端联调前,就可以验证前端 UI 逻辑。
  • 伸缩性:未来聚合支付 SDK 进行升级时,对已接入的业务不产生影响。
  • 隐藏实现细节:上层业务无需关心底层支付流程细节。
  • 与 Jetpack 无缝集成:采用 StateFlow 模式,利用 Hilt 管理对象生命周期。

设计思路

将上述设计目标分门别类后,思考对应的技术选型。

目标技术选型说明
安全性独立 module + 网络传输加密将支付模块作为独立模块,未来可以抽出 aar 集成,也便于混淆控制。下单、查询订单状态等接口,进行二次加密从而提升安全等级
通用性,伸缩性,隐藏实现细节Activity-ViewModel-Repository-DataSource 的分层设计PaySdkClient(聚合支付 SDK)作为 DataSource 对象,只对 Repository 可见
可测试性PayRepository 声明为接口,并提供 PayRepositoryImplFakePayRepository 两个实现APP 开发阶段使用 FakePayRepository,联调阶段切换为 PayRepositoryImpl
与 Jetpack 无缝集成Hilt + Flow使用 Hilt 进行单例作用域管理,使用 Flow 实现支付状态优雅流转

架构图

  • Domain 层可选,当业务场景复杂度高、有复用的需求时,会将其封装出 Domain 层的 UseCase
  • ViewModel 既可以引用 UseCase,也可以引用 Repository
  • Repository 层的每个模块,提供了真实实现和 Fake 实现,后者可用于开发阶段自测、自动化测试。
  • 层与层之间单向依赖,通过 Hilt 进行实例管理和注入。

image.png

时序图

  • 两层订单号设计
    • 业务订单号:由业务(例如健身课程)后台生成,是业务系统内标识,描述用户买了哪门课、多少钱等。
    • 支付交易号:由支付公司生成,在支付渠道内唯一定位这笔支付。
    • 两层订单号要做绑定关系,方便对账查账。
  • 支付后台 ---支付结果通知--> 业务后台
    • 回调:支付后台在用户完成支付后,会向商户预先配置的回调地址(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)
}