支付系统核心逻辑 — — 状态机(JavaGolang版本)

news2024/11/28 22:45:28

支付系统核心逻辑 — — 状态机

代码地址:https://github.com/ziyifast/ziyifast-code_instruction/tree/main/state_machine_demo

1 概念:FSM(有限状态机),模式之间转换

状态机,也叫有限状态机(FSM,Finite State Machine),是一种行为模式,是由一组定义良好的状态、状态之间的转换规则和一个初始状态组成。

  • 根据当前的状态和输入的事件,从一个状态转移到另一个状态。

2 实战:支付核心逻辑

2.1 支付交易三重奏:收单、结算、拒付款

下图中我们可以看到,一共4种状态,每个状态之间的转换都通过指定事件触发。
在这里插入图片描述

2.2 状态机设计原则

无论是设计支付类的系统,还是电商类的系统,在设计状态机时,都建议遵循以下原则

  1. 明确性:状态和转换必须清晰定义,避免含糊不清的状态。
  2. 完备性:为所有可能的事件-状态组合定义转换逻辑。
  3. 可预测性:系统应根据当前状态和给定事件可预测地响应。
  4. 最小化:状态数应保持最小,避免不必要的复杂性。
①明确性:状态与转换必须定义清晰
②完备性:需要考虑所有事件-状态的转换组合
③可预测性:需根据当前状态+给定事件可预测响应
④最小化:状态数要少,避免过于复杂
常见误区
  1. 过度设计:引入不必要的状态
  2. 不完备的处理:没有考虑到状态与事件所有可能的转换关系,导致系统行为不确定
  3. 硬编码逻辑:过多硬编码转换逻辑,导致系统不具备可扩展性和灵活性

比如下面的设计:

一眼看过去,好像除了复杂一点,整体还是合理的,比如初始化,受理成功就到ACCEPT,然后到PAYING,如果直接成功就到PAIED,退款成功就到REFUND。

在这里插入图片描述

不合理的地方:

  1. 流程复杂。第一眼看过去会发现不那么清晰,流程比较繁琐,比较复杂,有很多状态都可以简化或者舍去。比如ACCEPT没有存在的必要。
  2. 职责不明确。支付单只管支付,到PAIED就算支付成功,最终状态不再改变。不应该后面还有REFUND状态。REFUND应该由退款单来负责处理,否则如果客户部分退款,我们就不好处理了。

改进方案:

  • 删除不必要的状态。如:ACCEPT
  • 将一个大型状态机抽取为多份小的状态机。比如把一些退款REFUND、请款等单据单独抽取出来。这个样子,虽然状态机数量多了,但是每个状态机都更加清晰明了。
  1. 主单:
    在这里插入图片描述
  1. 普通支付单
    在这里插入图片描述
  1. 预授权单
    在这里插入图片描述
  1. 请款单
    在这里插入图片描述
  1. 退款单
    在这里插入图片描述
最佳实践及代码规范

代码层面:

  1. 分离状态和处理逻辑:使用状态模式,将每个状态的行为都封装在各自的类中
  2. 使用事件驱动模型:通过事件来触发状态转换,而不是直接调用状态方法
  3. 确保可追踪性:状态转换应被记录和追踪,以便故障排查和审计

上面几点也就要求我们不应该使用if else或者switch case来写,会让代码看起来复杂。我们应该将每个状态封装为单独的类。

2.3 Java版本实现

  1. 定义状态基类
/**
 * 状态基类
 */
public interface BaseStatus {
}
  1. 定义事件基类
/**
 * 事件基类
 */
public interface BaseEvent {
}
  1. 定义状态-事件对,指定的状态只能接受指定的事件
/**
 * 状态事件对,指定的状态只能接受指定的事件
 */
public class StatusEventPair<S extends BaseStatus, E extends BaseEvent> {
    /**
     * 指定的状态
     */
    private final S status;
    /**
     * 可接受的事件
     */
    private final E event;

    public StatusEventPair(S status, E event) {
        this.status = status;
        this.event = event;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof StatusEventPair) {
            StatusEventPair<S, E> other = (StatusEventPair<S, E>)obj;
            return this.status.equals(other.status) && this.event.equals(other.event);
        }
        return false;
    }

   @Override
    public int hashCode() {
         // 这里使用的是google的guava包。com.google.common.base.Objects
         return Objects.hashCode(status, event);
     }
}
  1. 定义状态机
/**
 * 状态机
 */
public class StateMachine<S extends BaseStatus, E extends BaseEvent> {
    private final Map<StatusEventPair<S, E>, S> statusEventMap = new HashMap<>();

    /**
     * 只接受指定的当前状态下,指定的事件触发,可以到达的指定目标状态
     */
    public void accept(S sourceStatus, E event, S targetStatus) {
        statusEventMap.put(new StatusEventPair<>(sourceStatus, event), targetStatus);
    }

    /**
     * 通过源状态和事件,获取目标状态
     */
    public S getTargetStatus(S sourceStatus, E event) {
        return statusEventMap.get(new StatusEventPair<>(sourceStatus, event));
    }
}
  1. 定义支付状态机。注:支付、退款等不同的业务状态机是独立的。
/**
 * 支付状态机
 */
public enum PaymentStatus implements BaseStatus {

    INIT("INIT", "初始化"),
    PAYING("PAYING", "支付中"),
    PAID("PAID", "支付成功"),
    FAILED("FAILED", "支付失败"),
    ;

    // 支付状态机内容
    private static final StateMachine<PaymentStatus, PaymentEvent> STATE_MACHINE = new StateMachine<>();
    static {
        // 初始状态
        STATE_MACHINE.accept(null, PaymentEvent.PAY_CREATE, INIT);
        // 支付中
        STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
        // 支付成功
        STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_SUCCESS, PAID);
        // 支付失败
        STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_FAIL, FAILED);
    }

    // 状态
    private final String status;
    // 描述
    private final String description;

    PaymentStatus(String status, String description) {
        this.status = status;
        this.description = description;
    }

    /**
     * 通过源状态和事件类型获取目标状态
     */
    public static PaymentStatus getTargetStatus(PaymentStatus sourceStatus, PaymentEvent event) {
        return STATE_MACHINE.getTargetStatus(sourceStatus, event);
    }
}
  1. 定义支付事件。注:支付、退款等不同业务的事件是不一样的。
/**
 * 支付事件
 */
public enum PaymentEvent implements BaseEvent {
    // 支付创建
    PAY_CREATE("PAY_CREATE", "支付创建"),
    // 支付中
    PAY_PROCESS("PAY_PROCESS", "支付中"),
    // 支付成功
    PAY_SUCCESS("PAY_SUCCESS", "支付成功"),
    // 支付失败
    PAY_FAIL("PAY_FAIL", "支付失败");

    /**
     * 事件
     */
    private String event;
    /**
     * 事件描述
     */
    private String description;

    PaymentEvent(String event, String description) {
        this.event = event;
        this.description = description;
    }
}
  1. 在支付单模型中声明状态和根据事件推进状态的方法:
/**
 * 支付单模型
 */
public class PaymentModel {
    /**
     * 其它所有字段省略
     */

    // 上次状态
    private PaymentStatus lastStatus;
    // 当前状态
    private PaymentStatus currentStatus;


    /**
     * 根据事件推进状态
     */
    public void transferStatusByEvent(PaymentEvent event) {
        // 根据当前状态和事件,去获取目标状态
        PaymentStatus targetStatus = PaymentStatus.getTargetStatus(currentStatus, event);
        // 如果目标状态不为空,说明是可以推进的
        if (targetStatus != null) {
            lastStatus = currentStatus;
            currentStatus = targetStatus;
        } else {
            // 目标状态为空,说明是非法推进,进入异常处理,这里只是抛出去,由调用者去具体处理
            throw new StateMachineException(currentStatus, event, "状态转换失败");
        }
    }
}

代码注释已经写得很清楚,其中StateMachineException是自定义,不想定义的话,直接使用RuntimeException也是可以的。

在支付业务代码中的使用:只需要paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()))

/**
 * 支付领域域服务
 */
public class PaymentDomainServiceImpl implements PaymentDomainService {

    /**
     * 支付结果通知
     */
    public void notify(PaymentNotifyMessage message) {
        PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
        try {
            
        	// 状态推进
        	paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()));
        	savePaymentModel(paymentModel);
        	// 其它业务处理
        	... ...
        } catch (StateMachineException e) {
            // 异常处理
            ... ...
        } catch (Exception e) {
            // 异常处理
            ... ...
        }
    }
}

上面的代码只需要加完善异常处理,优化一下注释,就可以直接用起来。

上面写法的好处:

  1. 定义了明确的状态、事件。
  2. 状态机的推进,只能通过“当前状态、事件、目标状态”来推进,不能通过if else 或case switch来直接写。比如:STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
  3. 避免终态变更。比如线上碰到if else写状态机,渠道异步通知比同步返回还快,异步通知回来把订单更新为“PAIED”,然后同步返回的代码把单据重新推进到PAYING。

2.4 Golang版本实现

项目结构:
在这里插入图片描述

①定义基础状态机:base_state_machine.go
package model

type BaseStatus interface {
}

type BaseEvent interface {
}

type StatusEventPair struct {
	status BaseStatus
	event  BaseEvent
}

func (pair StatusEventPair) equals(other StatusEventPair) bool {
	return pair.status == other.status && pair.event == other.event
}

type StateMachine struct {
	statusEventMap map[StatusEventPair]BaseStatus
}

func (sm *StateMachine) accept(sourceStatus BaseStatus, event BaseEvent, targetStatus BaseStatus) {
	pair := StatusEventPair{status: sourceStatus, event: event}
	sm.statusEventMap[pair] = targetStatus
}

func (sm *StateMachine) getTargetStatus(sourceStatus BaseStatus, event BaseEvent) BaseStatus {
	pair := StatusEventPair{status: sourceStatus, event: event}
	baseStatus := sm.statusEventMap[pair]
	return baseStatus
}
②定义支付状态机:payment_state_machine.go
package model

type PaymentStatus string

const (
	INIT   PaymentStatus = "INIT"
	PAYING PaymentStatus = "PAYING"
	PAID   PaymentStatus = "PAID"
	FAILED PaymentStatus = "FAILED"
)

type PaymentEvent string

const (
	PAY_CREATE  PaymentEvent = "PAY_CREATE"
	PAY_PROCESS PaymentEvent = "PAY_PROCESS"
	PAY_SUCCESS PaymentEvent = "PAY_SUCCESS"
	PAY_FAIL    PaymentEvent = "PAY_FAIL"
)

var PaymentStateMachine = StateMachine{statusEventMap: map[StatusEventPair]BaseStatus{}}

func init() {
	//支付状态机初始化,包含所有可能的情况
	PaymentStateMachine.accept(nil, PAY_CREATE, INIT)
	PaymentStateMachine.accept(INIT, PAY_PROCESS, PAYING)
	PaymentStateMachine.accept(PAYING, PAY_SUCCESS, PAID)
	PaymentStateMachine.accept(PAYING, PAY_FAIL, FAILED)
}

func GetTargetStatus(sourceStatus PaymentStatus, event PaymentEvent) PaymentStatus {
	status := PaymentStateMachine.getTargetStatus(sourceStatus, event)
	if status != nil {
		return status.(PaymentStatus)
	}
	panic("获取目标状态失败")
}

type PaymentModel struct {
	lastStatus    PaymentStatus
	CurrentStatus PaymentStatus
}

func (pm *PaymentModel) TransferStatusByEvent(event PaymentEvent) {
	targetStatus := GetTargetStatus(pm.CurrentStatus, event)
	if targetStatus != "" {
		pm.lastStatus = pm.CurrentStatus
		pm.CurrentStatus = targetStatus
	} else {
		// 处理异常
		panic("状态转换失败")
	}
}
③使用及测试

main.go:

package main

import (
	"github.com/kataras/iris/v12"
	"github.com/kataras/iris/v12/context"
	"github.com/ziyifast/log"
	"myTest/demo_home/state_machine_demo/model"
	"time"
)

var (
	testOrder = new(model.PaymentModel)
)

func main() {
	application := iris.New()
	application.Get("/order/create", createOrder)
	application.Get("/order/pay", payOrder)
	application.Get("/order/status", getOrderStatus)
	application.Listen(":8899", nil)
}

func createOrder(context *context.Context) {
	testOrder.CurrentStatus = model.INIT
	context.WriteString("create order succ...")
}

func payOrder(context *context.Context) {
	testOrder.TransferStatusByEvent(model.PAY_PROCESS)
	log.Infof("call third api....")
	//调用第三方支付接口和其他业务处理逻辑
	time.Sleep(time.Second * 15)
	log.Infof("done...")
	testOrder.TransferStatusByEvent(model.PAY_SUCCESS)
}

func getOrderStatus(context *context.Context) {
	context.WriteString(string(testOrder.CurrentStatus))
}

声明:为了快速验证以及让代码更加简洁,没有按照标准的规范来编写controller、service、dao等。

测试:

  1. 启动程序,调用create接口,创建订单
http://localhost:8899/order/create

在这里插入图片描述

  1. 调用支付接口支付订单
http://localhost:8899/order/pay

我们手动模拟调用第三方支付接口,sleep了几十秒(实际调用肯定比这个快多了),所以不会立即返回结果,我们需要新开一个窗口,直接查询订单状态

在这里插入图片描述

  1. 立即调用查询接口获取订单状态,查看是否为支付中
http://localhost:8899/order/status

在这里插入图片描述

  1. 等待支付成功后,调用接口查看订单状态,是否为已支付

等待后台日志打印done之后重新调用查询接口:

在这里插入图片描述

http://localhost:8899/order/status

在这里插入图片描述

3 并发更新问题:多线程修改同一状态机(db版本号)

“状态机领域模型同时被两个线程操作怎么避免状态幂等问题?”
这是一个好问题。在分布式场景下,这种情况太过于常见。同一机器有可能多个线程处理同一笔业务,不同机器也可能处理同一笔业务。

业内通常的做法是设计良好的状态机 + 数据库锁 + 数据版本号解决。

在这里插入图片描述
简要说明:

  1. 状态机一定要设计好,只有特定的原始状态 + 特定的事件才可以推进到指定的状态。比如 INIT + 支付成功才能推进到sucess。
  2. 更新数据库之前,先使用select for update进行锁行记录,同时在更新时判断版本号是否是之前取出来的版本号,更新成功就结束,更新失败就组成消息发到消息队列,后面再消费。
  3. 通过补偿机制兜底,比如查询补单。

通过上述三个步骤,正常情况下,最终的数据状态一定是正确的。除非是某个系统有异常,比如外部渠道开始返回支付成功,然后又返回支付失败,说明依赖的外部系统已经异常,这样只能进人工差错处理流程。

参考文章:https://juejin.cn/post/7321569896453521419

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1599161.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

TCE测试管理平台-社区版(免费),手机端小程序如何使用?

TCE测试管理平台社区版&#xff08;免费&#xff09;是泽众云测试平台SAAS服务产品之一&#xff0c;提供给个人和企业免费使用&#xff0c;无需安装&#xff0c;注册即可使用。 TestCenter Enterprise&#xff08;简称TCE&#xff09;是由泽众软件开发的一款面向测试流程的测试…

深入K8S实战

K8S: 深入K8S实战进阶篇 1、搭建 Kubernetes 集群 1.1、搭建方案 1.1.1、minikube minikube 是一个工具&#xff0c; 能让你在本地运行 Kubernetes。 minikube 在你的个人计算机&#xff08;包括 Windows、macOS 和 Linux PC&#xff09;上运行一个一体化&#xff08;all-i…

【经典算法】LeetCode 136:只出现一次的数字(Java/C/Python3实现含注释说明,Easy)

个人主页&#xff1a; 进朱者赤 阿里非典型程序员一枚 &#xff0c;记录平平无奇程序员在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法&#xff08;公众号同名&#xff09; 目录 题目描述思路及实现方式一&#xff1a;使用异或运算&#xff08;推荐&#xff09;思…

【LatentDiffusion 代码详解(1)】LatentDiffusion 的 yaml 解读

YAML 文件提供了一种清晰、简洁且易于理解的方式来描述配置信息&#xff0c;特别适用于机器学习模型的超参数调优和实验管理。 以 Latent Diffusion 官方代码仓库中的 https://github.com/CompVis/latent-diffusion/blob/main/configs/autoencoder/autoencoder_kl_32x32x4.yam…

MySQL表结构的操作

文章目录 1. 创建表2. 查看表3. 修改表4. 删除表 1. 创建表 create table table_name (field1 datatype,field2 datatype,field3 datatype )character set 字符集 collate 校验集 engine 存储引擎;field&#xff1a;列名datatype&#xff1a;列的类型character set&#xff1a…

数字乡村创新实践探索农业现代化与乡村振兴新路径:科技赋能农村全面振兴与农民福祉新纪元

目录 引言 一、数字乡村与农业现代化新路径 1、智慧农业引领农业现代化 2、农业产业链的数字化转型 二、数字乡村与乡村振兴新路径 1、农村信息化水平的提升 2、农村治理模式的创新 三、科技赋能农村全面振兴与农民福祉新纪元 1、提升农业生产效益与农民收入 2、促进…

2024蓝桥杯——宝石问题

先展示题目 声明 以下代码仅是我的个人看法&#xff0c;在自己考试过程中的优化版&#xff0c;本人考试就踩了很多坑&#xff0c;我会—一列举出来。代码可能很多&#xff0c;但是总体时间复杂度不高只有0(N) 函数里面的动态数组我没有写开辟判断和free&#xff0c;这里我忽略…

企业网站建设需要了解什么

在现代商业环境中&#xff0c;企业网站已经成为企业宣传、推广和销售的重要工具。企业网站的建设需要考虑多个因素&#xff0c;包括以下几个方面&#xff1a; 首先&#xff0c;了解企业的目标和定位。企业网站的建设应该围绕企业的目标和定位展开&#xff0c;以达到企业在市场中…

基于python的景点舆情分析,爬虫获取景点数据,评论数据,情感分析和可视化展示

概述 毕业设计项目中&#xff0c;构建一个基于Python的系统&#xff0c;用于抓取携程网上的江西省景点信息及对应评论数据&#xff0c;并进行深入的舆情分析。 数据获取&#xff1a; 使用Python的requests库模拟浏览器发送HTTP请求至指定URL&#xff08;https://m.ctrip.com/r…

HW机试1

1.字符串翻转函数 string s;reverse(s.begin(),s.end()); 2.不区分大小写统计字符出现的个数 需要判断是大小写字母。根据ASCII码值&#xff0c;大写字母32小写字母 void func(string s,char ch) {int lens.length(),cnt0;if(ch>64){for(int i0;i<len;i){if(s[i]32ch…

用户相关的配置文件

1.新建用户的配置文件从哪里来的&#xff1f; 在Linux操作系统中都有一个默认行为&#xff0c;当在Linux操作系统中新建用户时&#xff0c;都会在/home/用户名 也就是新建用户的家目录中配置三个隐藏文件 分别是.bash-logout .bash-profile .bashrc 如上图所示&#xff…

解决WPS右键菜单冗余选项,去除WPS右键菜单选项

问题描述 安装WPS后&#xff0c;右键菜单会多出许多无用的选项&#xff0c;如何去除&#xff1f; 解决方法 按下WindowsS打开搜索栏&#xff0c;搜索配置工具打开 勾选所有的关闭和隐藏选项

通过Maven导入本地jar包

1.创建lib文件夹&#xff0c;把jar包放到文件夹里面 2.在pom里导入依赖 导入完成

AI数字人对话之RealChar框架源码解读

零.功能介绍 与虚拟角色(非形象)进行文本或语音会话 体验地址:RealChar. 代码库:GitHub - Shaunwei/RealChar: 🎙️🤖Create, Customize and Talk to your AI Character/Companion in Realtime (All in One Codebase!). Have a natural seamless conversation with AI…

2024年nodejs调用小红书最新关注(粉丝)follow接口,api接口分析2004-04-16

一、打开chrome按f12&#xff0c;点击右上角的“关注”按钮&#xff0c;抓包位置如下&#xff1a; (图1 follow接口) 二、follow接口分析 1、请求地址 https://edith.xiaohongshu.com/api/sns/web/v1/user/follow 2、请求方法: POST 3、请求头&#xff1a; :authority: edith…

【绘图案例-带圆环的图片-图片 Objective-C语言】

一、接下来,我们接着来说,带圆环的图片,图片, 1.我们还差这个,图片,啊, 图片还没有画上去啊,圆环已经画完了, 接下来呢,我们应该尝试着去画图片, // 12.画图片 image drawAtPoint:(CGPoint) 如果我直接这么着去画的话, 那么,实际上,就会把那个方形的东西,…

Linux Shell Script 编程详解

1. Shell Script入门介绍 1.1 什么是Shell Shell 是指一种应用程序&#xff0c;它是用户使用 Linux 的桥梁&#xff0c;这个应用程序提供了一个界面&#xff0c;用户通过这个界面访问操作系统内核的服务。Shell 脚本&#xff08;shell script&#xff09;&#xff0c;是一种为…

移动端web适配方案

以下是移动端适配的多个方案&#xff0c;也可以说说你是怎么做的。 正文 自适应&#xff1a;根据不同的设备屏幕大小来自动调整尺寸、大小 响应式&#xff1a;会随着屏幕的实时变动而自动调整&#xff0c;是一种更强的自适应 为什么要做移动端适配&#xff1f; 目前市面上…

亚马逊云科技数据工程师考试官方免费课程上线啦

自从上次小李哥分享了AWS Data Engineer Associate证书首通经验后&#xff0c;有非常多的小伙伴们问我&#xff0c;应该怎么复习这门考试呢&#xff1f; 这门考试是AWS针对最近大热&#x1f525;的AI、数据分析、数据科学等行业&#xff0c;推出的全新考试。因为刚刚推出&#…