news 2026/9/15 12:20:04

代付系统源码拆解:状态机、权限控制与API回调设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
代付系统源码拆解:状态机、权限控制与API回调设计

简介:一套面向代付业务场景的手工/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_PAYPAYING这一步必须做行级锁,否则两个代付员工同时打开列表,同时点击同一笔订单,就会发出两笔重复出款。

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 UPDATEWHERE 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 代付是商户系统自动调用平台接口触发,手工代付是运营人员在后台点击触发。

我比较推荐的单笔流程如下:

  1. 商户在商户后台或通过接口创建代付订单,系统校验余额、实名、金额上限;
  2. 订单进入PENDING_REVIEW,后台风控规则通过后自动流转到PENDING_PAY,规则命中的进入人工审核;
  3. 代付员工在待出款列表看到订单,点击出款;
  4. 后端锁单,调用上游通道出款接口,拿到受理号后置为PAYING
  5. 通道异步回调成功后置为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条件。为什么状态不在PAYINGPENDING_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.cssstyles.rtl.min.cssstyles.ltr.min.cssall.min.cssfrontend.css,说明这是典型的“后端模板渲染 + 前端管理后台”结构,不是前后端分离项目。rtlltr表示后台支持阿拉伯语/中文等方向性布局,这种目录通常来自 AdminLTE 一类的模板。源码放在 PHP 环境跑起来的概率很大,入口文件一般在public/index.php,路由配置在routes或控制器目录。拿到同类源码时,第一件事不要急着配域名,先看composer.json.env.exampleconfig/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,要把最后一段改成实际入口名。研究环境里还要把.envAPP_DEBUG关掉,否则异常信息会把数据库密码和 Redis 地址直接暴露到页面上。

5.3 用适配器模式扩展多通道

手工代付系统最常用的二次开发是增加新通道。不要直接在控制器里写“如果channel_code == alipay就调支付宝,如果是 bank 就调银联”,否则每增加一个通道都要改业务方法。我会把所有通道封装成统一接口:

interface PaymentChannelInterface { public function remit(array $order): array; public function query(string $channelOrderNo): array; }

每个通道一个实现类,例如AlipayTransferChannelBankTransferChannel,在通道配置表里维护channel_code到类名的映射。这样原有状态机和回调逻辑不用动,新通道只关注三件事:请求参数怎么组装、签名怎么算、回调怎么验。验证时先用通道提供的测试账号跑一笔最小金额代付,确认成功后再接入批量代付,避免批量文件一次全部失败。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 12:17:49

用ima构建个人知识库:从导入到AI检索的完整教程

很多人收藏了一堆好文章、存了一堆PDF和笔记&#xff0c;真到用的时候却翻不到、找不到&#xff0c;知识散落得到处都是。腾讯出的免费工具 ima&#xff0c;就是来解决这个问题的——它能把网页、文档、碎片想法统一收进一个个人知识库&#xff0c;然后用 AI 检索的方式直接问、…

作者头像 李华
网站建设 2026/9/15 12:16:10

Unity后处理实战指南:从URP配置到性能优化与调参心法

很多朋友做Unity项目做到最后&#xff0c;总觉得画面“差口气”。模型面数不低&#xff0c;贴图也是高清的&#xff0c;灯光该打的也打了&#xff0c;但渲染出来就是干巴巴的&#xff0c;没有那种“商业项目”的质感。这时候&#xff0c;八成是后处理&#xff08;Post Processi…

作者头像 李华
网站建设 2026/9/15 12:16:06

Serverless AI运行时:智能体开发的新架构演进

1. 项目概述&#xff1a;Serverless AI运行时的演进与价值十年前我第一次接触Serverless架构时&#xff0c;就被其"按需付费、免运维"的特性所吸引。如今在AI应用爆发式增长的背景下&#xff0c;传统Serverless运行时已经无法满足智能体开发的需求。最近我在为金融行…

作者头像 李华