PHP支付系统设计与典型案例分享

由于公司业务需要,花两周时间实现了一个小型的支付系统,麻雀虽小五脏俱全,各种必须的模块如账户加锁,事务性保证,流水对帐等都是有完整实现的,整个开发过程中有很多经验积累,再加上在网上搜索了一下,大部分都是些研究性的论文,对实际使用价值不大,所以这次特意拿出来和大家分享一下。
这个系统可以用作小型支付系统,也可以用做第三方应用接入开放平台时的支付流水系统。
原来的需求比较负责,我简化一点说:

对每个应用,对外需要提供 获取余额,支付设备,充值 等接口
后台有程序,每月一号进行清算
账户可以被冻结
需要记录每一次操作的流水,每天的流水都要和发起方进行对账

针对上面的需求,我们设置如下数据库:

CREATE TABLE `app_margin`.`tb_status` (
`appid` int(10) UNSIGNED NOT NULL,
`freeze` int(10) NOT NULL DEFAULT 0,
`create_time` datetime NOT NULL,
`change_time` datetime NOT NULL, 

PRIMARY KEY (`appid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

CREATE TABLE `app_margin`.`tb_account_earn` (
`appid` int(10) UNSIGNED NOT NULL,
`create_time` datetime NOT NULL,
`balance` bigint(20) NOT NULL,
`change_time` datetime NOT NULL,
`seqid` int(10) NOT NULL DEFAULT 500000000, 

PRIMARY KEY (`appid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

CREATE TABLE `app_margin`.`tb_bill` (
`id` int AUTO_INCREMENT NOT NULL,
`bill_id` int(10) NOT NULL,
`amt` bigint(20) NOT NULL,
`bill_info` text, 

`bill_user` char(128),
`bill_time` datetime NOT NULL,
`bill_type` int(10) NOT NULL,
`bill_channel` int(10) NOT NULL,
`bill_ret` int(10) NOT NULL, 

`appid` int(10) UNSIGNED NOT NULL,
`old_balance` bigint(20) NOT NULL,
`price_info` text, 

`src_ip` char(128), 

PRIMARY KEY (`id`),
UNIQUE KEY `unique_bill` (`bill_id`,`bill_channel`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

CREATE TABLE `app_margin`.`tb_assign` (
`id` int AUTO_INCREMENT NOT NULL,
`assign_time` datetime NOT NULL, 

PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

CREATE TABLE `app_margin`.`tb_price` (
`name` char(128) NOT NULL,
`price` int(10) NOT NULL,
`info` text NOT NULL, 

PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

CREATE TABLE `app_margin`.`tb_applock` (
`appid` int(10) UNSIGNED NOT NULL,
`lock_mode` int(10) NOT NULL DEFAULT 0,
`change_time` datetime NOT NULL, 

PRIMARY KEY (`appid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

INSERT `app_margin`.`tb_assign` (`id`,`assign_time`) VALUES (100000000,now());

详细解释如下:
tb_status 应用的状态表。负责账户是否被冻结,账户的类型是什么(真实的需求是应用可能有两种账户,这里为简单所以没有列出)
appid 应用id
freeze 是否冻结
create_time 创建时间
change_time 最后一次修改时间
tb_account_earn 应用的账户余额表
appid 应用id
balance 余额(单位为分,不要用小数存储,因为小数本身不精确;另外php要在64位机下才能支持bigint)
create_time 创建时间
change_time 最后一次修改时间
seqid 操作序列号(防并发,每次update都会+1)
tb_assign 分配流水id的表,tb_bill的bill_id必须是有tb_assign分配的
id 自增id
create_time 创建时间
tb_bill 流水表。负责记录每一条操作流水,这里的bill_id不是主键,因为同一个bill_id可能会有支付和回滚两条流水
id 自增序列号
bill_id 流水号
amt 操作的金额(这个是要区别正负的,主要是为了select all的时候可以直接计算出某段时间的金额变化)
bill_info 操作的详细信息,比如3台webserver,2台db
bill_user 操作用户
bill_time 流水时间
bill_type 流水类型,区分是加钱还是减钱
bill_channel 流水来源,如充值,支付,回滚,结算还是其他
bill_ret 流水的返回码,包括未处理、成功、失败,这里的逻辑会在后面讲解
appid 应用id
old_balance 操作发生前的账户余额
price_info 记录操作发生时,记录被支付物品的单价
src_ip 客户端ip
tb_price 单价表,记录了机器的单价
name 机器唯一标识
price 价格
info 描述
tb_applock 锁定表,这是为了避免并发对某一个应用进行写操作设计的,具体的代码会在后面展示
appid 应用id
lock_mode 锁定状态。为0则为锁定,为1则为锁定
change_time 最后一次修改时间
OK,库表设计出来之后,我们就来看一下最典型的几个操作.

一. 支付操作
我这里只列出了我目前实现的方式,可能不是最好的,但应该是最经济又满足需求的。
先说调用方这里,逻辑如下:

然后对应的支付系统内部逻辑如下(只列出支付操作,回滚逻辑差不多,流水检查是要检查对应的支付流水是否存在):

常用的错误返回码可能如下就足够了:

$g_site_error = array(
-1 => '服务器繁忙',
-2 => '数据库读取错误',
-3 => '数据库写入错误', 

0 => '成功', 

1 => '没有数据',
2 => '没有权限',
3 => '余额不足',
4 => '账户被冻结',
5 => '账户被锁定',
6 => '参数错误',
);

对于大于0的错误都算是逻辑错误,执行支付操作,调用方是不用记录流水的。因为账户并没有发生任何改变。
对于小于0的错误是系统内部错误,因为不知道是否发生了数据更改,所以调用方和支付系统都要记录流水。
对于等于0的返回,代表成功,两边也肯定要记录流水。
而在支付系统内部,之所以采用先写入流水,再进行账户更新的方式也是有原因的,简单来说就是尽量避免丢失流水。
最后总结一下,这种先扣钱,再发货,出问题再回滚的方式是一种模式;还有一种是先预扣,后发货,没有出问题则调用支付确认来扣款,出了问题就调用支付回滚来取消,如果预扣之后很长时间不做任何确认,那么金额会自动回滚。

二. 账户锁定的实现
这里利用了数据库的加锁机制,具体逻辑就不说了,代码如下:

class AppLock
{
function __construct($appid)
{
$this->m_appid = $appid;
//初始化数据
$this->get();
} 

function __destruct()
{
$this->free();
} 

public function alloc()
{
if ($this->m_bGot == true)
{
return true;
} 

$this->repairData(); 

$appid = $this->m_appid;
$ret = $this->update($appid,APPLOCK_MODE_FREE,APPLOCK_MODE_ALLOC);
if ($ret === false)
{
app_error_log("applock alloc fail");
return false;
}
if ($ret <= 0)
{
app_error_log("applock alloc fail,affected_rows:$ret");
return false;
}
$this->m_bGot = true;
return true;
} 

public function free()
{
if ($this->m_bGot != true)
{
return true;
} 

$appid = $this->m_appid;
$ret = $this->update($appid,APPLOCK_MODE_ALLOC,APPLOCK_MODE_FREE);
if ($ret === false)
{
app_error_log("applock free fail");
return false;
}
if ($ret <= 0)
{
app_error_log("applock free fail,affected_rows:$ret");
return false;
}
$this->m_bGot = false;
return true;
} 

function repairData()
{
$db = APP_DB(); 

$appid = $this->m_appid; 

$now = time(); 

$need_time = $now - APPLOCK_REPAIR_SECS; 

$str_need_time = date("Y-m-d H:i:s", $need_time); 

$db->where("appid",$appid);
$db->where("lock_mode",APPLOCK_MODE_ALLOC);
$db->where("change_time <=",$str_need_time); 

$db->set("lock_mode",APPLOCK_MODE_FREE);
$db->set("change_time","NOW()",false); 

$ret = $db->update(TB_APPLOCK);
if ($ret === false)
{
app_error_log("repair applock error,appid:$appid");
return false;
}
return true;
} 

private function get()
{
$db = APP_DB(); 

$appid = $this->m_appid; 

$db->where('appid', $appid); 

$query = $db->get(TB_APPLOCK); 

if ($query === false)
{
app_error_log("AppLock get fail.appid:$appid");
return false;
} 

if (count($query->result_array()) <= 0)
{
$applock_data = array(
'appid'=>$appid,
'lock_mode'=>APPLOCK_MODE_FREE,
);
$db->set('change_time','NOW()',false);
$ret = $db->insert(TB_APPLOCK, $applock_data);
if ($ret === false)
{
app_error_log("applock insert fail:$appid");
return false;
} 

//重新获取数据
$db->where('appid', $appid);
$query = $db->get(TB_APPLOCK); 

if ($query === false)
{
app_error_log("AppLock get fail.appid:$appid");
return false;
}
if (count($query->result_array()) <= 0)
{
app_error_log("AppLock not data,appid:$appid");
return false;
}
}
$applock_data = $query->row_array();
return $applock_data;
} 

private function update($appid,$old_lock_mode,$new_lock_mode)
{
$db = APP_DB(); 

$db->where('appid',$appid);
$db->where('lock_mode',$old_lock_mode); 

$db->set('lock_mode',$new_lock_mode);
$db->set('change_time','NOW()',false); 

$ret = $db->update(TB_APPLOCK);
if ($ret === false)
{
app_error_log("update applock error,appid:$appid,old_lock_mode:$old_lock_mode,new_lock_mode:$new_lock_mode");
return false;
}
return $db->affected_rows();
} 

//是否获取到了锁
public $m_bGot = false; 

public $m_appid;
}

为了防止死锁的问题,获取锁的逻辑中加入了超时时间的判断,大家看代码应该就能看懂

三. 对帐逻辑
如果按照上面的系统来设计,那么对帐的时候,只要对一下两边成功(即bill_ret=0)的流水即可,如果完全一致那么账户应该是没有问题的,如果不一致,那就要去查问题了。
关于保证账户正确性这里,也有同事跟我说,之前在公司做的时候,是采取只要有任何写操作之前,都先取一下流水表中所有的流水记录,将amt的值累加起来,看得到的结果是否和余额相同。如果不相同应该就是出问题了。
select sum(amt) from tb_bill where appid=1;
所以这也是为什么我在流水表中,amt字段是要区分正负的原因。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • php版交通银行网银支付接口开发入门教程

    本文实例讲述了php版交通银行网银支付接口实现方法.分享给大家供大家参考,具体如下: 概述:网银支付接口 和支付宝接口大体上差不多,主要的区别是 交通银行提供的 接口核心的比如,加密等是通过java实现的,所以,要想办法使php和java能正常的通信,为此,官方也提供了两套实现方法,一个是通过 socket 进行通信,另一个方法是通过 java 桥接,下面演示的是 socket方法. 1. 配置运行环境 1.1  安装java,自行到oracle官网下载 java,然后安装,并配置正确的 环境变

  • php微信支付接口开发程序

    php微信支付接口开发程序讲解: 必要条件: appid //公众号后台开发者中心获得(和邮件内的一样) mchid//邮件内获得 key//商户后台自己设置 appsecret //公众号开发者中心获得 两个证书文件,邮件内获得 apiclient_cert.pem   apiclient_key.pem 注意事项: 公众号后台微信支付->开发配置->新增测试目录和测试个人微信号. 开发者中心->网页授权获取用户基本信息->修改成你的测试域名.否则会出现redirect_uri

  • 浅谈使用PHP开发微信支付的流程

    下面以PHP语言为例,对微信支付的开发流程进行一下说明. 1.获取订单信息 2.根据订单信息和支付相关的账号生成sign,并且生成支付参数 3.将支付参数信息POST到微信服务器,获取返回信息 4.根据返回信息生成相应的支付代码(微信内部)或是支付二维码(非微信内),完成支付. 下面分步骤的讲一下: 1.微信支付中相关的必须的订单参数有三个,分别是:body(商品名或订单描述),out_trade_no(一般为订单号)和total_fee(订单金额,单位"分",要注意单位问题),在不同

  • PHP实现的交通银行网银在线支付接口ECSHOP插件和使用例子

    最近,一个项目要求做交通银行在线支付,ecshop本身没有这方面的接口,于是通过一些时间的专研,做了一个插件出来.有好的东西,当然要分享,在此特地分享出来,希望能够帮助到跟我一样有需要的人,为大家减轻一下开发的负担,也多请大家指出一些好的方法和建议,相互的学习.进步! 在使用插件之前,请配置好交通银行在线支付的环境(具体安装方法,交行提供的demo会有,也不是很难,注意好细节就行).安装好之后,请将把下面的插件源码和语言包源码按路径保存到相应文件,最后进入后台的支付模块安装即可. 插件源码(in

  • Thinkphp微信公众号支付接口

    本文实例为大家分享了Thinkphp微信公众号支付接口,供大家参考,具体内容如下 第一步  先把文件夹的那两个图片 配置成一样的路径 除了域名要改 其他保持一致. 第二步  把 Weixinpay 这个文件夹放在 \ThinkPHP\Library\Vendor  将Weixinpay文件夹放置到这个Vendor文件夹中 第三步  把  WxJsAPIController.class.php 这个php文件  \Home\Controller  这里面 第四步  把 WxJsAPI这个文件夹 

  • 微信支付PHP SDK之微信公众号支付代码详解

    这里假设你已经申请完微信支付 1. 微信后台配置  如图 我们先进行测试,所以先把测试授权目录和 测试白名单添加上.测试授权目录是你要发起微信请求的哪个文件所在的目录. 例如jsapi 发起请求一般是jsapi.php所在目录 为测试目录,测试白名单即开发人员的微信号. 正式的支付授权目录不能和测试的一样否则会报错.不填写或者填错授权目录以及测试白名单都会报错. 报错样例: NaNsystem:access_denied 不在测试白名单 2. 配置 lib/WxPay.Config.php文件

  • 解析PayPal支付接口的PHP开发方式

    申请PayPal注册网址:https://www.paypal.com/ paypal接口与其它接口有些不同,稍微复杂一点. 其实银行接口也算是一个站点的插件. 所谓paypal ipn(Instant Payment Notification),就是Paypal开发的一种能主动通知第三方卖家系统交易状态的一种机制.IPN的原理很简单,就是当产生了一个交易之后, 交易状态发生变化时,如用户已经付款.或者退款.撤销时,Paypal利用常用的HTTP POST方式,将交易的一些变量提交给网站的某个页

  • PHP微信支付实例解析

    本文为大家分享了PHP微信支付实例,包括PHP微信支付源码,PHP微信退款源码,php微信支付接口,供大家参考,具体内容如下 1.JSapi支付demo(在微信客户端中点击) <?php /** * JS_API支付demo * ==================================================== * 在微信浏览器里面打开H5网页中执行JS调起支付.接口输入输出数据格式为JSON. * 成功调起支付需要三个步骤: * 步骤1:网页授权获取用户openid *

  • PHP 微信支付类 demo

    一切尽在代码中,代码附有注释,欢迎大家参考. <?php class WxpayService { protected $mchid; protected $appid; protected $key; public function __construct($mchid, $appid, $key) { $this->mchid = $mchid; // 微信支付商户号 PartnerID 通过微信支付商户资料审核后邮件发送 $this->appid = $appid; //公众号AP

  • php支付宝在线支付接口开发教程

    1.什么是第三方支付 所谓第三方支付,就是一些和各大银行签约.并具备一定实力和信誉保障的第三方独立机构提供的交易支持平台.在通过第三方支付平台的交易中,买方选购商品后,使用第三方平台提供的账户进行货款支付,由第三方通知卖家货款到达. 目前提供第三方支付的机构很多,常见的有支付宝.财付通.快钱.网银在线.易宝支付.云网等各大支付平台.网站如果需要实现第三方支付首先应该向第三方支付平台申请一个账号并签署协议,协议生效后第三方支付平台将为其开通在线支付功能,通过程序将接口集成到网站中. 为什么要使用第

  • PHP微信支付开发实例

    PHP微信支付开发过程,分享给大家,供大家参考,具体内容如下 1.开发环境 Thinkphp 3.2.3 微信:服务号,已认证 开发域名:http://test.paywechat.com (自定义的域名,外网不可访问) 2.需要相关文件和权限 微信支付需申请开通 微信公众平台开发者文档:http://mp.weixin.qq.com/wiki/home/index.html 微信支付开发者文档:https://pay.weixin.qq.com/wiki/doc/api/index.html

  • PHP 接入支付宝即时到账功能

    首先请到支付宝那边申请一个及时到账的接口账户,需要提交相关材料申请.然后根据即时到账的API文档进行接入.API文档提供了各种语言版本的demo,我这里是下的php版demo,然后再进行相关修改操作.你也可以将demo版本的代码重新整合,我这里暂时为了走通充值及时到账流程,就不进行代码重构了. API文档:https://doc.open.alipay.com/docs/doc.htm?spm=a219a.7386797.0.0.eZb8FX&treeId=108&articleId=10

随机推荐