ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

代付系统源码拆解:状态机、权限控制与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_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, encodingutf-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, }其中encodingutf-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 不同中文语音需要显式选择langzh-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 码升序排列值不为空的参数拼接成keyvalue用连接最后在末尾拼接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) fkey{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_no40003余额不足平台代付专户余额不足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到类名的映射。这样原有状态机和回调逻辑不用动新通道只关注三件事请求参数怎么组装、签名怎么算、回调怎么验。验证时先用通道提供的测试账号跑一笔最小金额代付确认成功后再接入批量代付避免批量文件一次全部失败。本文还有配套的精品资源点击获取
返回列表