简介:一套面向代付业务场景的手工/API代付系统源码,适合有PHP开发基础的工程师或小微支付团队研究、二次开发及业务部署。系统整体设计简洁,支持单笔与批量代付,同时提供API自动对接与后台手动出款两条路径,后台-代理-代付员工-商户四个角色权限分明,并配有白名单登录、谷歌验证、出款语音播报等安全与体验功能。压缩包共1879个文件,体积约26.03MB,以PHP业务代码、SVG/JPEG/PNG等界面素材、HTML/JS前端页面、MP3语音提示以及SQL数据库脚本为主,结构完整,能够帮助读者快速认识代付系统的基本架构和业务流程。资源内含批量代付场景中的常见流程记录与音频提醒素材,便于了解系统实际操作反馈。目前已有265人学习下载,如果希望从零掌握代付系统的角色划分、接口对接与常见功能实现,这套源码值得参考。
1. 拆这套 3888 的手工代付系统源码,我优先看的是状态机
做支付系统这一行,很多人以为代付就是把银行接口包一层,真正拆过源码才发现,七成工作量压在“订单别重复、状态别错乱、权限别越级”这三件事上。这套标价 3888 的源码,正好把手工代付、批量代付、API 代付放到同一个后台,再加上白名单、谷歌验证和语音提醒,适合正在做账户体系或支付网关的人当参考样本。它要解决的核心问题很具体:多个角色同时操作系统时,如何保证每笔钱只出一次,并且每个动作都能追溯。后面我会按角色权限、状态流转、批量任务、API 回调、部署改造的顺序去拆,最后留一个扩展多通道的思路。需要先说明一点,这类系统涉及真实资金,源码只能用于研究学习,真实业务必须在持牌合规前提下改造。
2. 代付系统的角色权限与状态机设计
2.1 四端模型:后台、代理、代付员工、商户
系统权限模型与普通电商后台最大的不同,在于它把“操作资金”和“审核资金”两种权限分开处理。商户只能提交代付请求;代理可以批量导入名下商户的代付清单;代付员工负责把待出款订单推送给上游通道;后台管理员掌握白名单、密钥和回调配置。如果把“能点击出款”和“能审核订单”放到同一个人身上,人工操作风险就会成倍放大,所以拆源码时我首先看路由分组和服务端中间件,判断权限是不是真的在后端做了检查,而不是只在前端隐藏按钮。
常见做法是在 PHP 控制器里对每个操作做can('withdraw:pay')之类的权限点判断,而不是简单检查is_admin。这样后面加通道时,运营人员可以只给代付员工分配“出款”权限,不分配“审核”权限。代理需要看到名下所有商户的累计出款额,但不能看到商户完整 API 密钥;代付员工只能看到自己所属小组的代付批次,避免互相抢单。
为了更直观,这套源码默认的权限矩阵可以整理成下面这样:
| 操作 | 商户 | 代理 | 代付员工 | 后台管理员 |
|---|---|---|---|---|
| 发起单笔代付 | 是 | 是 | 可配置 | 是 |
| 批量上传代付清单 | 部分 | 是 | 否 | 是 |
| 审核代付订单 | 否 | 部分 | 是 | 是 |
| 执行出款操作 | 否 | 否 | 是 | 是 |
| 配置出款白名单 | 否 | 否 | 否 | 是 |
| 查看 API 密钥 | 否 | 部分 | 否 | 是 |
这里的“部分”是关键。代理如果要批量代付,就必须能看到商户的订单号和出款明细,但不应该看到完整 API 密钥;密钥等在管理员页面也建议脱敏显示,只显示前四位和后四位。实际接入时我一般还会再加一层代理等级控制:一级代理只能操作自己下级的商户,二级代理只能操作直属商户,否则一个代理能替全平台商户代付,权限就失控了。权限判断要放在服务端每一个修改资金状态的方法里,不能只依赖前端按钮显隐。
2.2 代付订单的状态流转与并发控制
人工操作最怕两件事:同一笔订单被两个员工同时点出款,或者回调还没返回,出款人又点了一次。要理解手工代付系统,先把订单状态机画清楚。参考这套源码常见设计,代付订单通常经历:
| 状态编码 | 含义 | 进入条件 | 离开条件 |
|---|---|---|---|
PENDING_REVIEW | 待审核 | 商户/API 提交成功 | 审核通过或驳回 |
PENDING_PAY | 待出款 | 人工审核通过 | 员工锁定出款 |
PAYING | 出款中 | 上游接口已受理 | 收到成功/失败回调 |
SUCCESS | 已出款成功 | 回调确认成功 | 不可逆 |
FAILED | 出款失败 | 回调确认失败或超时 | 可重新发起 |
REJECTED | 已驳回 | 审核不通过 | 不可再出款 |
状态字段建议用字符串而不是数字,因为对接不同通道时日志里直接看到PENDING_PAY,语义更明确,排查问题不用翻字典。状态字段上必须有索引,因为所有拉单操作都会按状态查;更关键的是从PENDING_PAY到PAYING这一步必须做行级锁,否则两个代付员工同时打开列表,同时点击同一笔订单,就会发出两笔重复出款。
BEGIN; SELECT * FROM withdraw_order WHERE order_no = 'DD20240601001' AND status = 'PENDING_PAY' FOR UPDATE; -- 应用层在这里调用上游通道接口,并拿到通道受理号 UPDATE withdraw_order SET status = 'PAYING', operator_id = 1001, channel_order_no = 'CH20240601001', updated_at = NOW() WHERE order_no = 'DD20240601001' AND status = 'PENDING_PAY'; COMMIT;这段 SQL 的关键是FOR UPDATE和WHERE status两个条件。FOR UPDATE会让同一时间只有第一个事务能读到这行,第二个事务必须等第一个事务提交后才能继续,所以第二个员工执行SELECT时看到的已经不是PENDING_PAY,说明该订单已被处理。更新语句要检查影响行数,若影响行数为 0,应用层直接提示“订单已被他人处理”,不要继续调用通道。即使以后系统从单机升级到多实例,这条 update 条件依然能兜底,是防重复出款性价比最高的方案。
2.3 白名单登录与谷歌验证的双因子落点
登录安全是手工代付系统容易被忽略但很吃配置的部分。账号一旦泄露,攻击者能登录后台直接发起出款,所以源码在账号密码之外又加了两个条件:IP 白名单和谷歌验证器。白名单解决“这个账号只能在指定网络环境下登录”,谷歌验证器解决“即使密码和 IP 都匹配,也必须再提供一次性动态码”。
拆开看登录流程是这样一个顺序:
def login(username, password, totp_code, remote_ip): user = authenticate_by_password(username, password) if user is None: return error(10001, "账号或密码错误") if not ip_in_whitelist(remote_ip, user.role): return error(10002, "当前 IP 不在白名单内") if not verify_totp(user.google_secret, totp_code): return error(10003, "动态验证码错误") token = create_session(user.id, remote_ip) write_security_log(user.id, "login_success", remote_ip) return ok(token)这段逻辑里有几个值得注意的点。authenticate_by_password必须与verify_totp解耦,不能把两步合在一个函数里,否则日志里无法区分是密码错还是动态码错。ip_in_whitelist要按角色取白名单:商户登录可以宽松,后台管理员和代付员工必须绑定固定出口 IP 段。实际部署时应用常挂在 Nginx 后面,代码拿到的remote_ip其实是网关 IP,所以需要在 Nginx 里配置X-Real-IP传递,然后代码从可信代理头取 IP,否则白名单会永远匹配不上线下办公网络。
3. 手工代付与批量代付的实现路径
3.1 单笔手工代付的完整流程
“手工代付”很容易被误解成工作人员登录银行网银手动转账,实际不是。它的准确含义是系统把待出款订单逐笔展示给代付员工,员工只做“确认出款”这个动作,真正请求上游支付通道的仍然是代码。跟 API 代付的核心区别在于触发方:API 代付是商户系统自动调用平台接口触发,手工代付是运营人员在后台点击触发。
我比较推荐的单笔流程如下:
- 商户在商户后台或通过接口创建代付订单,系统校验余额、实名、金额上限;
- 订单进入
PENDING_REVIEW,后台风控规则通过后自动流转到PENDING_PAY,规则命中的进入人工审核; - 代付员工在待出款列表看到订单,点击出款;
- 后端锁单,调用上游通道出款接口,拿到受理号后置为
PAYING; - 通道异步回调成功后置为
SUCCESS,失败则置为FAILED并允许重新提交。
这样的设计把“人工决策”和“请求通道”放在同一个事务里,避免员工以为点过了但其实没有请求通道。代码层面常见写法是:
$order = WithdrawOrder::where('order_no', $request->input('order_no')) ->where('status', WithdrawOrder::STATUS_PENDING_PAY) ->lockForUpdate() ->first(); if (!$order) { return $this->error('订单不存在或已被处理'); } $result = PaymentChannelFactory::make($order->channel_code)->remit([ 'order_no' => $order->order_no, 'amount' => $order->amount, 'account_no' => $order->account_no, 'account_name' => $order->account_name, 'bank_name' => $order->bank_name, ]); if ($result->isSuccess()) { $order->status = WithdrawOrder::STATUS_PAYING; $order->channel_order_no = $result->getChannelOrderNo(); $order->save(); }上面的lockForUpdate对应第 2 章讲的行锁思路;PaymentChannelFactory把不同通道的差异封装起来。很多人写的时候会把“调用通道”和“状态更新”分开放在两个函数里,中间没有锁,结果员工双击表单提交时发出两笔请求。正确做法是锁单、调用、更新状态连贯执行,并且在回调返回前不要释放锁。channel_order_no一定要保存,后面异步查单和回调对账都靠它。
3.2 批量代付的文件上传与批次拆分
批量代付主要面向代发工资、批量结算这类场景。商户上传包含收款人、账号和金额的表格,系统解析后逐笔创建代付请求。源头文件格式必须固定,不能允许用户随便传 xlsx,因为 xlsx 解析会引入大量模板约定,出问题不好排查。常见做法是提供一个 CSV 模板,商户下载后按列填写再上传,模板列名约定为:商户订单号、收款账号、收款户名、开户行支行、金额(元)。
解析时我会先写一个独立脚本离线验证,而不是直接把$_FILES文件内容导入数据库:
import csv from decimal import Decimal def parse_withdraw_csv(file_path): with open(file_path, newline='', encoding='utf-8-sig') as fp: reader = csv.DictReader(fp) for row in reader: order_no = row['商户订单号'].strip() amount = Decimal(row['金额(元)'].strip()) if amount <= 0: raise ValueError(f"订单 {order_no} 金额必须大于 0") yield { "merchant_order_no": order_no, "account_no": row["收款账号"].strip(), "account_name": row["收款户名"].strip(), "bank_name": row["开户行支行"].strip(), "amount": amount, }其中encoding='utf-8-sig'是为了去掉 Excel 保存 CSV 时插入的 BOM 头,否则第一列表头会变成“商户订单号”前带不可见字符;金额用Decimal而不是 float,是因为 float 的二进制精度会使 100.01 变成 100.009999,最后传给通道时金额尾差被拒;用yield而不是直接返回数组,是防止上传两万行时把内存打满。
解析完成后要按批次入库。上游通道一般会限制单批次最大笔数,比如 300 笔,批量文件即使有 5000 笔也要自动拆批。我的默认参数如下:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 单批次最大笔数 | 200 | 根据通道文档调整 |
| 文件编码 | UTF-8 | 带 BOM,兼容 Excel |
| 金额字段 | Decimal | 避免浮点误差 |
| 重复检查 | 商户订单号 | 同一批次内不允许重复 |
同一批次共用同一个batch_no,后续对账按批次查询,单笔失败只重发该笔而不是整批重发。批次表和订单表通过batch_no关联,批次的汇总金额必须等于所有订单金额之和,否则不允许提交,这个校验在人工审核前就要做掉。
3.3 代付出款语音播报的实现
语音播报看起来是花活,但对代付员工来说是刚需。员工同时开着多个订单页面,不可能一直盯着列表刷新。系统在有新代付订单进入PENDING_PAY时,通过 WebSocket 推送事件到员工页面,浏览器再调用语音合成接口播报。实现不复杂,前端常见代码:
ws.onmessage = function (event) { const msg = JSON.parse(event.data); if (msg.type === 'withdraw_new') { const text = `新代付订单,金额 ${msg.amount} 元`; const utterance = new SpeechSynthesisUtterance(text); speechSynthesis.speak(utterance); } };这里有两个细节。第一,SpeechSynthesisUtterance在不同浏览器里的默认 voice 不同,中文语音需要显式选择lang='zh-CN'的 voice,否则金额会被读成英文数字。第二,批量导入 5000 笔时,后端不要一次性推 5000 条 WebSocket 消息,否则浏览器连续播报会卡死。常见做法是在后端做一个 1 秒合并窗口:同一秒内到达的订单合并成一条“新到代付订单 35 笔,合计 8421.50 元”。
后端事件推送也不要直接从订单服务推到每个浏览器,最好引入 Redis 发布订阅。前端连接的是 WebSocket 网关服务,订单服务只负责把withdraw_new事件发布到 Redis channel,网关收到后再推给所有已登录的代付员工。这样做的好处是订单服务重启不丢消息,后续要同时给企业微信、钉钉机器人推送时,只需要多写一个订阅者。如果只是快速验证,可以直接在WithdrawOrder模型创建事件里触发广播,但生产环境建议还是走独立队列。
4. API 代付对接的鉴权与回调设计
4.1 API 代付的签名算法与字段要求
API 代付是这套系统里最值钱的部分。它的本质是商户系统调用本平台的代付接口,本平台再调用上游通道。对外要解决“请求是不是来自身份合法的商户”,对内要解决“上游通道请求怎么路由、订单怎么映射”。一个合格的 RESTful API 接口规范在这里比具体语言更重要,因为商户侧可能用 PHP、Java、Python 任意技术栈,平台给出的接口必须足够稳定。
常用字段可以整理成下表:
| 参数 | 含义 | 是否必填 | 说明 |
|---|---|---|---|
mch_id | 商户号 | 是 | 商户在平台的唯一标识 |
out_trade_no | 商户订单号 | 是 | 全局唯一,重复会被拒绝 |
amount | 金额 | 是 | 单位分,整数 |
account_no | 收款账号 | 是 | 银行卡号或钱包账号 |
account_name | 收款户名 | 是 | 与证件一致 |
bank_name | 开户行 | 否 | 银行卡代付时建议传 |
notify_url | 回调地址 | 是 | 公网可访问的 HTTP(S) 地址 |
sign | 签名 | 是 | 见下方算法 |
金额单位必须用“分”,这是最容易出现问题的点。如果商户传的是元,100.55 元转成字符串传给银行会造成对不上。签名算法通常是先将除sign外所有参数按 ASCII 码升序排列,值不为空的参数拼接成key=value,用&连接,最后在末尾拼接&key=商户密钥,再取 MD5 并转大写。
import hashlib def make_sign(params: dict, secret: str) -> str: items = sorted( (k, v) for k, v in params.items() if k != "sign" and str(v) != "" ) raw = "&".join(f"{k}={v}" for k, v in items) + f"&key={secret}" return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()排序必须放在第一步,很多签名错误都来自排序不一致。有时商户端把amount写成100.55,平台端收到的是字符串"100.55",只要两边一致也能通过;最怕的是平台端把金额转成 int 后参与签名,商户端用原始字符串参与,结果签名永远对不上。所以接口文档要写清楚字符串格式,金额单位虽是分,但仍以字符串传递,不要用数值类型参与拼接。
4.2 回调通知与幂等处理
商户调用代付接口后,平台不能同步返回最终结果,只能先返回已受理,等上游通道异步通知后,平台再回调商户的notify_url。回调报文的验签逻辑和请求侧类似,但要注意回调报文可能是表单格式也可能是 JSON,先按请求头判断。收到回调后第一件事验签,第二件事查订单是否存在,第三件事才是更新状态。
public function notify(Request $request) { $params = $request->input(); if (!verifySign($params, $this->merchantSecret)) { return response('fail'); } $order = WithdrawOrder::where('merchant_order_no', $params['out_trade_no']) ->whereIn('status', [ WithdrawOrder::STATUS_PAYING, WithdrawOrder::STATUS_PENDING_PAY ]) ->first(); if (!$order) { return response('fail'); } event(new WithdrawResultReceived($order, $params)); return response('success'); }这段代码里最容易被忽略的是whereIn条件。为什么状态不在PAYING、PENDING_PAY时直接忽略?因为系统可能已经收到过一次成功回调,把订单置成SUCCESS,如果第二次回调再进来,就不能再对已成功订单执行更新,否则会把历史正确结果覆盖。返回字符串success而不是 JSON 数组,是很多通道的约定;如果返回其他内容,通道会认为通知失败并继续重试。为了幂等,我还会在回调入口记录withdraw_notify_log,把每次回调的原始报文、请求 IP 和当前状态都存下来,排查“商户说没收到回调”时可以快速定位。
4.3 常见失败场景与排错顺序
API 代付对接失败的原因一半在签名,另一半在状态更新顺序。把常见的失败信息做成速查表:
| 错误码 | 含义 | 常见原因 |
|---|---|---|
40001 | 签名错误 | 排序不一致、密钥带空格、参数字段名大小写不统一 |
40002 | 订单号重复 | 商户重复提交同一out_trade_no |
40003 | 余额不足 | 平台代付专户余额不足 |
40004 | 账号不匹配 | 户名与银行账号非同一人 |
40005 | 通道不可用 | 上游接口报错或触发限流 |
排查时我一般按这个顺序。第一步看服务器时间与标准时间差是否超过 300 秒,TOTP 动态码和谷歌验证都与时间有关,时间飘偏会导致验签和动态码同时失败。第二步看日志里的request_body与商户文档示例是否一致,尤其留意密钥复制时末尾是否被粘进换行符。第三步打开回调日志表,看平台有没有收到上游回调;如果上游显示已回调但平台没记录,检查防火墙和 Nginx 日志,确认回调请求是否到达 PHP-FPM。最后看状态更新有没有被 where 条件拦住,很多“回调成功但订单没变成功”的问题不是因为代码没执行,而是订单状态不在允许更新的范围里。
5. 把这套源码变成自己的技术资产:部署与二次开发技巧
5.1 从静态资源命名识别技术栈
原始文件列表里出现styles.css、styles.rtl.min.css、styles.ltr.min.css、all.min.css、frontend.css,说明这是典型的“后端模板渲染 + 前端管理后台”结构,不是前后端分离项目。rtl和ltr表示后台支持阿拉伯语/中文等方向性布局,这种目录通常来自 AdminLTE 一类的模板。源码放在 PHP 环境跑起来的概率很大,入口文件一般在public/index.php,路由配置在routes或控制器目录。拿到同类源码时,第一件事不要急着配域名,先看composer.json、.env.example和config/database.php,这三个文件决定了依赖、环境变量和数据表结构。
5.2 部署时的 Nginx 与安全配置
如果判断项目是 PHP 框架,Nginx 伪静态是第一个坑。很多人在 Windows 下能跑起来,换到 Linux 后访问首页出现 404,大多是伪静态规则没生效。常见配置是:
location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }try_files的作用是:如果用户访问的路径不是真实文件,就把请求交给index.php处理,由框架路由决定对应控制器。如果项目入口不是index.php,要把最后一段改成实际入口名。研究环境里还要把.env的APP_DEBUG关掉,否则异常信息会把数据库密码和 Redis 地址直接暴露到页面上。
5.3 用适配器模式扩展多通道
手工代付系统最常用的二次开发是增加新通道。不要直接在控制器里写“如果channel_code == alipay就调支付宝,如果是 bank 就调银联”,否则每增加一个通道都要改业务方法。我会把所有通道封装成统一接口:
interface PaymentChannelInterface { public function remit(array $order): array; public function query(string $channelOrderNo): array; }每个通道一个实现类,例如AlipayTransferChannel、BankTransferChannel,在通道配置表里维护channel_code到类名的映射。这样原有状态机和回调逻辑不用动,新通道只关注三件事:请求参数怎么组装、签名怎么算、回调怎么验。验证时先用通道提供的测试账号跑一笔最小金额代付,确认成功后再接入批量代付,避免批量文件一次全部失败。
本文还有配套的精品资源,点击获取