ホームページ  >  記事  >  php教程  >  典型的な PHP 決済システムの設計と実装

典型的な PHP 決済システムの設計と実装

WBOY
WBOYオリジナル
2016-06-21 08:51:071176ブラウズ

会社のビジネス ニーズにより、小規模な決済システムの実装には 2 週間かかりました。小規模ではありますが、アカウント ロック、トランザクション保証、会計照合などのさまざまな必要なモジュールが完全に実装されています。その過程で多くの経験を積んできましたが、インターネットで検索してもほとんど実用的価値のない研究論文が多かったので、今回は特別に公開します。

このシステムは、小規模な支払いシステムとして、またはサードパーティのアプリケーションがオープン プラットフォームに接続されている場合の支払いフロー システムとして使用できます。

元の要求はより責任のあるものです。少し単純化して次のように言います。

  1. アプリケーションごとに、残高、支払い機器、リチャージなどを取得するための外部インターフェースを提供する必要があります。
  2. バックグラウンドでプログラムがあり、毎月1日に清算が行われます
  3. アカウントは凍結される可能性があります
  4. 各操作の流れを記録する必要があり、一日の流れを開始者と調整する必要があります

上記の要件に応えて、次のデータベースをセットアップしました:

					1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
					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 アプリケーションステータステーブル。アカウントが凍結されているかどうか、およびそのアカウントの種類を担当します (実際の要件は、アプリケーションに 2 種類のアカウントがある可能性があるため、わかりやすくするためにここにはリストされていません)。
    • appid アプリケーション ID
    • フリーズ フリーズするかどうか
    • create_time 作成時刻
    • change_time 最終更新時刻
  • tb_account_earn アプリケーションのアカウント残高テーブル
    • appid アプリケーション ID
    • バランス 残高 (単位はセントです。小数自体は正確ではないため、保存には小数を使用しないでください。また、bigint をサポートするには、php が 64 ビット マシン上にある必要があります)
    • create_time 作成時刻
    • change_time 最終更新時刻
    • seqid 操作のシーケンス番号 (同時実行防止、更新ごとに +1)
  • tb_assign はシリアル ID を割り当てるテーブルです。tb_bill の bill_id は tb_assign によって割り当てられる必要があります。
    • id 自動インクリメント ID
    • create_time 作成時刻
  • tb_bill フロー テーブル。各操作フローの記録を担当します。同じ bill_id に支払いとロールバックの 2 つのフローがある可能性があるため、ここでの bill_id は主キーではありません。
    • ID自動インクリメントシリアル番号
    • bill_id シリアル番号
    • amt操作量(これはプラスとマイナスを区別するためであり、主に全選択時に一定期間内の量の変化を直接計算できるようにするためのものです)
    • bill_info 操作の詳細 (3 つの Web サーバー、2 db
    • など)
    • bill_user 操作ユーザー
    • bill_time 請求時間
    • bill_type 請求タイプ、お金を追加するか減算するかを区別します
    • bill_channel 水源 (再チャージ、支払い、ロールバック、決済など)
    • bill_ret 未処理、成功、失敗を含む請求の戻りコード。ここでのロジックは後で説明します。
    • appid アプリケーション ID
    • old_balance 操作が行われる前のアカウント残高
    • Price_info は、記録操作が発生したときに支払われる商品の単価を記録します
    • src_ip クライアント IP
  • tb_price 単価リスト、マシンの単価を記録します。
    • name マシンの一意の識別子
    • 価格 価格
    • 情報の説明
  • tb_applock はテーブルをロックします。これは、アプリケーションへの同時書き込み操作を回避するように設計されています。具体的なコードは後で説明します。
    • appid アプリケーション ID
    • lock_mode ロック ステータス。 0の場合はロックされており、1の場合はロックされています
    • change_time 最終更新時刻

ライブラリ テーブルを設計したら、最も一般的な操作をいくつか見てみましょう。

1. 支払い操作

ここでは私が現在実装している方法のみを記載します。これが最善ではないかもしれませんが、最も経済的でニーズを満たすものであるはずです。

まず呼び出し元について説明します。ロジックは次のとおりです。

対応する支払いシステムの内部ロジックは次のとおりです (支払い操作のみがリストされ、ロールバック ロジックは同様で、フロー チェックは対応する支払いフローが存在するかどうかを確認することです):

一般的に使用されるエラー戻りコードは、次のように十分です。

					1
2
3
4
5
6
7
8
9
10
11
12
13
14
					$g_site_error = array(
    -1 => '服务器繁忙',
    -2 => '数据库读取错误',
    -3 => '数据库写入错误',
 
    0 => '成功',
 
    1 => '没有数据',
    2 => '没有权限',
    3 => '余额不足',
    4 => '账户被冻结',
    5 => '账户被锁定',
    6 => '参数错误',
);
  1. 对于大于0的错误都算是逻辑错误,执行支付操作,调用方是不用记录流水的。因为账户并没有发生任何改变。
  2. 对于小于0的错误是系统内部错误,因为不知道是否发生了数据更改,所以调用方和支付系统都要记录流水。
  3. 对于等于0的返回,代表成功,两边也肯定要记录流水。

而在支付系统内部,之所以采用先写入流水,再进行账户更新的方式也是有原因的,简单来说就是尽量避免丢失流水。

最后总结一下,这种先扣钱,再发货,出问题再回滚的方式是一种模式;还有一种是先预扣,后发货,没有出问题则调用支付确认来扣款,出了问题就调用支付回滚来取消,如果预扣之后很长时间不做任何确认,那么金额会自动回滚。

二. 账户锁定的实现

这里利用了数据库的加锁机制,具体逻辑就不说了,代码如下:

					1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
					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(
                &#39;appid&#39;=>$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的值累加起来,看得到的结果是否和余额相同。如果不相同应该就是出问题了。

					1
					select sum(amt) from tb_bill where appid=1;

所以这也是为什么我在流水表中,amt字段是要区分正负的原因。

OK,整篇文章写的很长,希望对坚持读完的同学有所帮助。



声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。