ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flask订单支付系统实战:支付宝微信接入与回调状态机设计

Flask订单支付系统实战:支付宝微信接入与回调状态机设计 简介基于Python和Flask框架搭建的订单支付系统完整集成了支付宝支付与微信支付两大主流支付渠道面向计算机相关专业学生适用于毕业设计、课程设计及项目初期演示解决支付流程对接、订单状态管理与项目部署等典型问题。压缩包内共28个文件以14个Python源码文件为核心覆盖Flask应用配置、支付路由与订单业务逻辑3个pem证书文件用于支付平台密钥通信3个HTML页面提供前端交互另有Markdown部署文档与说明文档整体包体仅34KB结构紧凑、便于快速查阅。目前已有122人学习下载项目代码经测试运行成功并获得导师指导认可答辩评审分达到95分。从部署文档入手可快速搭建运行环境对照源码即可理解支付宝与微信支付的接入流程也可在此基础上扩展订单管理、退款对账等功能适合作为高分毕设参考或Python进阶学习。1. 从毕设到生产订单支付系统的选型与落地边界很多同学拿到PythonFlask的订单支付系统这类毕设项目第一反应是跑通代码、截图答辩但很少有人关心一件事支付宝和微信支付的接入方式完全是两套体系Flast 作为轻量框架如何同时兼容异步回调、签名验签和订单状态机这个项目给我的启发是它不只是一个 demo而是一个“麻雀虽小五脏俱全”的支付中台雏形——用户下单、选择支付渠道、发起预支付、接收异步通知、修改订单状态、处理退款每个环节都有真实业务场景。适合正在做课设、毕设的人也适合想快速了解支付对接流程的企业开发人员。下面我根据项目源码和部署文档拆解订单支付系统从数据表设计到线上部署的完整链路给出可直接套用的代码和参数。2. 订单与支付记录的数据模型先想清楚状态机再写表支付系统的核心不是搞一个好看的支付按钮而是订单状态的一致性。这个项目里与支付直接相关的表是否合理我打开数据库脚本最值得讲的是orders和payment_records两张表之间的联动关系。订单表保存业务层面的信息支付表保存渠道交互的流水两者通过order_sn关联。2.1 订单表字段设计思路订单表至少包含order_sn业务单号自己生成不要用数据库自增 ID因为支付回调和第三方接口对单号长度和格式有要求、user_id、total_amount单位是分避免浮点误差、pay_type1支付宝2微信、status状态机、created_at、paid_at等。total_amount必须用整数分存储这是做支付项目的基本常识。如果沿用 Decimal 或 Float 算金额等到退款对账时一定会后悔。CREATE TABLE orders ( id int(11) NOT NULL AUTO_INCREMENT, order_sn varchar(32) NOT NULL COMMENT 业务订单号, user_id int(11) NOT NULL, total_amount int(11) NOT NULL COMMENT 总金额单位分, pay_type tinyint(4) DEFAULT NULL COMMENT 1-支付宝 2-微信, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消 3-已退款, created_at datetime NOT NULL, paid_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_sn (order_sn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的关键是status用整型而不是字符串。因为支付回调可能并发到达虽然我们不控并发但状态字段越简单越好判断。order_sn唯一索引是必须的否则回调重复时容易产生脏数据。我一般会生成订单号 时间戳 用户ID 随机数长度保持在 32 字符内因为支付宝要求商户订单号最长 64 字符微信要求 32 以内的数字或字母。2.2 支付流水表记录每一次渠道交互支付记录表需要记录设备信息、第三方交易号、回调报文、异步通知处理状态。这个项目里的payment_records表设计得比较聪明它把支付宝和微信统一到一张表中通过channel区分而不是给每个支付方式建一张表。字段包括id、order_sn、channelalipay/wechat、transaction_id第三方支付单号、raw_data回调原始报文 JSON、notify_status0未处理 1处理成功 2处理失败、created_at。CREATE TABLE payment_records ( id int(11) NOT NULL AUTO_INCREMENT, order_sn varchar(32) NOT NULL, channel varchar(16) NOT NULL, transaction_id varchar(64) DEFAULT NULL, raw_data json DEFAULT NULL COMMENT 回调原始请求体或报文, notify_status tinyint(4) DEFAULT 0, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_order_sn (order_sn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;JSON 类型的raw_data字段很实用——排查回调问题时可以直接把支付宝的原始通知存进去不用翻日志。在旧版本 MySQL 5.7 以下不支持 JSON可以改用 TEXT 字段但查询性能会差些。课程设计阶段可以放宽生产环境建议用独立的回调日志表。2.3 订单状态机防止重复回调把订单打回待支付状态机的定义要显式不能直接在业务代码里写 if status 1。我建议在配置里维护一个状态流转表当前状态允许的下一状态触发条件0 待支付1 已支付 / 2 已取消用户支付成功 / 超时未支付0 待支付3 已退款管理员强制退款无支付记录时1 已支付3 已退款用户申请退款并审核通过在回调处理函数里第一件事是查订单第二件事就是判断当前状态是否等于待支付。如果订单已经是已支付说明回调重复推送直接返回成功标志给支付平台不要再改数据库。Flask 里用装饰器封装一个状态校验函数比较优雅def update_order_status(order_sn, new_status): order Order.query.filter_by(order_snorder_sn).first() if not order: return False allowed ORDER_STATUS_TRANSITIONS.get(order.status, []) if new_status not in allowed: # 重复回调或非法跳转这里只记录日志不抛异常 return False order.status new_status if new_status 1: order.paid_at datetime.now() db.session.commit() return TrueORDER_STATUS_TRANSITIONS是一个字典比如{0: [1,2], 1: [3]}。这样改状态时就能快速看出非法流转。项目的实际代码里可能没有这么封装但你在做毕设时加入这个类答辩时就是加分项。3. Flask 项目结构与配置管理把敏感信息挡在代码之外这个项目的目录结构里有config.py、manage.py、pay_flask包、utils、keys等目录看得出来是用 Flask 应用工厂模式组织的。很多毕设项目把数据库连接串、支付密钥直接写死在视图文件里这个项目没有值得按生产标准来看。3.1 应用工厂与 Blueprint 拆分pay_flask/__init__.py里多半是创建 app 和注册蓝图。建议把路由按业务拆成pay和order两个 Blueprint而不是所有视图堆在一个文件里。下面是常见的工厂函数写法# pay_flask/__init__.py from flask import Flask from config import Config def create_app(config_classConfig): app Flask(__name__) app.config.from_object(config_class) from pay_flask.views import order_bp, pay_bp app.register_blueprint(order_bp, url_prefix/order) app.register_blueprint(pay_bp, url_prefix/pay) return appmanage.py则负责启动入口一般会加一个 CLI 命令初始化数据库# manage.py from flask.cli import FlaskGroup from pay_flask import create_app cli FlaskGroup(create_appcreate_app) if __name__ __main__: cli()然后命令行python manage.py runserver就能跑起来。这里的FlaskGroup可以让你直接用 Flask 命令行插件比如flask shell、flask db upgrade。注意create_app是 Flask 官方推荐的工厂函数写法测试时只需传入不同配置类比如TestingConfig。Project 里的Pay_Flask-master目录名带版本号部署时可以直接改名为Pay_Flask避免路径里出现连字符导致 import 时找不到包。3.2 配置分离与环境变量config.py是配置的集中地。我建议把所有环境相关的键做成变量而不是硬编码。下面这段配置适用于大多数 Flask 支付项目import os class Config: SECRET_KEY os.environ.get(SECRET_KEY) or dev-key SQLALCHEMY_DATABASE_URI os.environ.get(DATABASE_URL) or \ mysqlpymysql://root:123456localhost/pay_flask?charsetutf8mb4 SQLALCHEMY_TRACK_MODIFICATIONS False # 支付宝配置 ALIPAY_APP_ID os.environ.get(ALIPAY_APP_ID) ALIPAY_PRIVATE_KEY os.environ.get(ALIPAY_PRIVATE_KEY) # 应用私钥 ALIPAY_PUBLIC_KEY os.environ.get(ALIPAY_PUBLIC_KEY) # 支付宝公钥 ALIPAY_GATEWAY https://openapi.alipay.com/gateway.do # 微信支付配置 WECHAT_APP_ID os.environ.get(WECHAT_APP_ID) WECHAT_MCH_ID os.environ.get(WECHAT_MCH_ID) WECHAT_API_KEY os.environ.get(WECHAT_API_KEY) WECHAT_NOTIFY_URL os.environ.get(WECHAT_NOTIFY_URL)有人可能觉得直接写在 config.py 里更方便但毕设答辩时老师很容易问“如果代码上传 GitHub 怎么办”。README 里写.env然后把.env加到.gitignore这才是标准做法。这个项目里有.gitignore但没有.env.example建议你补一个示例文件把变量名列清楚不写真实值。3.3 密钥文件管理项目里有个keys目录里面存放密钥文件但注意密钥文件不能直接传进 Docker 镜像或推到公共仓库。常见做法是用utils里的封装函数读取文件内容# utils/crypto.py import os def read_key(name): key_path os.path.join(os.path.dirname(__file__), .., keys, name) with open(key_path, r) as f: return f.read().strip()然后配置文件里写ALIPAY_PRIVATE_KEY read_key(alipay_private.key)。这里有个细节支付宝的 Java 常使用 PKCS8 格式密钥Python 的 SDK 可以直接读取不带BEGIN RSA PRIVATE KEY头的纯字符串也可以整段读取。如果报错检查read_key()是否把换行符去掉了有些 SDK 要求密钥字符串里必须包含换行符去掉反而报错。4. 支付宝支付接入电脑网站支付与异步验签支付宝支付在 Flask 中的对接核心是统一下单、构造表单、处理异步通知、验签和回调返回。这个项目用的是支付宝电脑网站支付alipay.trade.page.pay用户在网页上点击支付跳转到支付宝收银台Android 和 iOS 也能适用只是 Uniapp 集成需要改用手机网站支付或者 App 支付但后端签名逻辑一致。4.1 生成支付表单的接口下单接口接收订单号构造支付宝参数这里推荐使用python-alipay-sdk这个三方库。如果项目里没有可以在requirements.txt里加上python-alipay-sdk3.3。关键代码如下# pay_flask/views/pay.py from flask import Blueprint, request, jsonify, render_template from alipay import AliPay from pay_flask import models from utils.crypto import read_key pay_bp Blueprint(pay, __name__) def get_alipay(): return AliPay( appidapp.config[ALIPAY_APP_ID], app_notify_urlNone, # 浏览器跳转后展示的页面 app_private_key_stringread_key(alipay_private.key), alipay_public_key_stringread_key(alipay_public.key), sign_typeRSA2, # RSA2 对应 SHA256withRSA debugFalse ) pay_bp.route(/alipay/create, methods[POST]) def alipay_create(): data request.get_json() order_sn data.get(order_sn) order models.Order.query.filter_by(order_snorder_sn).first() if not order: return jsonify(code400, msg订单不存在) alipay get_alipay() order_string alipay.api_alipay_trade_page_pay( out_trade_noorder_sn, total_amountorder.total_amount / 100, # 元支付宝用元金额转回 float subject订单支付-{}.format(order_sn), return_urlhttp://localhost:5000/pay/alipay/return, notify_urlhttp://your-domain.com/pay/alipay/notify ) pay_url https://openapi.alipay.com/gateway.do? order_string return jsonify(code200, data{pay_url: pay_url})total_amount从分转元时一定要除以 100且保留两位小数否则支付宝会报“订单金额无效”。out_trade_no必须唯一如果同一订单下单多次建议相同订单号重新生成支付链接覆盖旧的未支付订单。这样用户刷新页面不会生成多个未支付单。4.2 异步通知验签与幂等处理支付宝的异步通知是 POST 请求带sign参数。验签后还需要判断trade_status只有TRADE_SUCCESS或TRADE_FINISHED才是真正的支付成功。下面的代码是完整的回调处理pay_bp.route(/alipay/notify, methods[POST]) def alipay_notify(): data request.form.to_dict() alipay get_alipay() success alipay.verify(data, data.pop(sign)) if not success: return failure trade_status data.get(trade_status) out_trade_no data.get(out_trade_no) trade_no data.get(trade_no) if trade_status in (TRADE_SUCCESS, TRADE_FINISHED): # 保存原始回调到支付记录表 record models.PaymentRecord( order_snout_trade_no, channelalipay, transaction_idtrade_no, raw_datajson.dumps(data), ) db.session.add(record) # 更新订单状态注意幂等 if update_order_status(out_trade_no, 1): db.session.commit() # 这里可发送短信或通知用户 return jsonify({code: SUCCESS}) # 支付宝要求返回纯文本 success return failure注意两个细节verify方法内部会把sign_type、sign参数剔除只拿剩余参数验签。自己手写验签时也记得不要包含sign本身。返回值必须是纯文本success小写不能是 JSON不能带引号否则支付宝会认为回调失败并不断重试每 30 秒重试一次最多 8 次直接打爆你的业务日志。调试阶段最容易犯的错是把return_url当成回调处理业务逻辑。return_url是用户付款后浏览器跳转的页面它不一定能保证到达不能在里面更新订单状态只能用来展示“支付成功”。真正的状态更新必须在notify_url里。4.3 支付宝沙箱环境快速体验联调时使用支付宝开放平台沙箱账号。在沙箱里网关地址是https://openapi.alipaydev.com/gateway.do可以在Config里加一个ALIPAY_DEBUGTrue然后动态拼网关地址。沙箱应用不需要真实营业执照下载支付宝开放平台密钥工具生成 RSA2 密钥即可。注意新注册的沙箱应用的公钥模式一定要把你的应用公钥填写到沙箱应用后台再用支付宝公钥进行验证很多同学做完下单后回调一直验签失败就是因为把应用公钥写成了支付宝公钥。5. 微信支付接入Native 与 JSAPI 双模式实现微信支付的逻辑和支付宝不同它采用 API v3 版本后支持证书和密钥两种方案小程序支付走 JSAPI 模式PC 网站走 Native 模式。本项目里同时支持支付宝和微信那么微信支付建议兼容两种场景。5.1 微信支付的统一下单与请求签名微信支付 API v3 要求每个请求都带Authorization头使用商户私钥对请求签名。项目keys目录里通常有apiclient_key.pem这是微信支付商户 API 私钥注意不要泄露。下面是用requests手动发请求的代码避免引入太重的 SDKimport json import time import requests from cryptography.hazmat.primitives import serialization def wechat_prepay(order_sn, total_fee, openidNone, trade_typeNATIVE): url https://api.mch.weixin.qq.com/v3/pay/transactions/native if trade_type JSAPI: url https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi timestamp str(int(time.time())) nonce_str .join(random.choices(abcdefghijklmnopqrstuvwxyz0123456789, k32)) body { appid: app.config[WECHAT_APP_ID], mchid: app.config[WECHAT_MCH_ID], description: 支付订单-{}.format(order_sn), out_trade_no: order_sn, notify_url: app.config[WECHAT_NOTIFY_URL], amount: {total: total_fee, currency: CNY} } if openid: body[payer] {openid: openid} body_json json.dumps(body, ensure_asciiFalse) message POST\n{}\n{}\n{}\n{}\n.format(url, timestamp, nonce_str, body_json) signature sign_message(message) headers { Authorization: WECHATPAY2-SHA256-RSA2048 mchid{} nonce_str{} timestamp{} signature{} serial_no{}.format( app.config[WECHAT_MCH_ID], nonce_str, timestamp, signature, app.config[WECHAT_SERIAL_NO] ), Content-Type: application/json, Accept: application/json } resp requests.post(url, databody_json, headersheaders) return resp.json()这个代码里sign_message是核心函数from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding def sign_message(message_str): with open(keys/apiclient_key.pem, rb) as f: private_key serialization.load_pem_private_key(f.read(), passwordNone) signature private_key.sign( message_str.encode(utf-8), padding.PKCS1v15(), hashes.SHA256() ) return b64encode(signature).decode()注意message换行拼接的格式请求方法\nURL\n时间戳\n随机串\n请求体\n最后还要一个换行。如果 signature 一直验签失败就把请求体 JSON 的 key 顺序固定推荐用字典保持插入顺序不要用sort_keysTrue因为微信官方要求原始字符串就是请求体的字节流。5.2 Native 模式返回 code_url 生成二维码Native 下单成功返回code_url前端用这个链接生成二维码用户扫码后微信内部发起支付。返回的数据里code_url有效期 2 小时过期后需要重新下单。建议在后端缓存 code_url避免用户刷新页面重复创建订单if order.status 0: result wechat_prepay(order_sn, order.total_amount) code_url result.get(code_url) # 将 code_url 存到订单表或 Redis 里keyorder_sn前端生成二维码一般用qrcode库这里不展开。注意微信 Native 支付不能返回支付链接让用户去浏览器打开只能扫码。5.3 微信支付回调解密与验签微信支付 API v3 的回调与支付宝不同回调请求头里有Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce请求体是加密的resource对象。验签后还需要使用 APIv3 密钥解密ciphertext。完整流程如下pay_bp.route(/wechat/notify, methods[POST]) def wechat_notify(): headers request.headers body request.get_data() wechatpay_signature headers.get(Wechatpay-Signature) wechatpay_timestamp headers.get(Wechatpay-Timestamp) wechatpay_nonce headers.get(Wechatpay-Nonce) serial_no headers.get(Wechatpay-Serial) # 1. 验证签名 message {}\n{}\n{}\n{}\n.format(wechatpay_timestamp, wechatpay_nonce, body.decode(), ) signature_bytes base64.b64decode(wechatpay_signature) # 使用平台证书验证这里略见下面说明 # 2. 解密 resource request.get_json().get(resource) ciphertext base64.b64decode(resource.get(ciphertext)) associated_data resource.get(associated_data).encode() nonce resource.get(nonce).encode() api_v3_key app.config[WECHAT_API_V3_KEY].encode() # 32字节密钥 from cryptography.hazmat.primitives.ciphers.aead import AESGCM aesgcm AESGCM(api_v3_key) plaintext aesgcm.decrypt(nonce, ciphertext, associated_data) data json.loads(plaintext) if data.get(trade_state) SUCCESS: out_trade_no data[out_trade_no] transaction_id data[transaction_id] update_order_status(out_trade_no, 1) return {code: SUCCESS, message: 成功} return {code: FAIL, message: 失败}验签部分需要下载微信支付平台证书证书可以从接口获取一次后缓存在本地。代码示例因篇幅不展开但要注意微信支付的回调必须返回状态码 200返回体是 JSON 时不能使用200以外的状态码。另外回调可能重复推送这里在update_order_status中通过状态机保证幂等如果订单状态不是待支付直接返回成功不做更新。5.4 小程序支付 JSAPI 模式的差异如果用于小程序需要在前端调用wx.requestPayment。后端下单时要拿到用户的openid这需要先调用code2Session接口。可以和 Native 模式共用同一个视图函数根据是否传入openid判断是 Native 还是 JSAPI。前端参数wx.requestPayment({ timeStamp: data.timeStamp, nonceStr: data.nonceStr, package: data.package, // prepay_idxxx signType: RSA, paySign: data.paySign });这里的paySign是后端用预支付交易会话标识prepay_id生成的签名格式和请求签名类似只是package要拼接prepay_id. 这里有一个高频坑JSAPI 下单时trade_typeJSAPI必须传openid否则报INVALID_ARGUMENT。6. 项目部署上线从 Flask 开发服务器到 gunicorn Nginx之前我们已经把业务代码写好了但 Flask 自带的开发服务器不支持并发而且性能极低正式部署需要使用 gunicorn 或 uwsgi。项目里的logs目录就是为了收集日志准备的部署文档也强调要用进程守护工具。部署场景推荐方式说明课程设计演示python manage.py runserver局域网可用仅演示低并发个人项目gunicorn Nginx2-4 个 worker支持 HTTPS中小型生产gunicorn supervisor Nginx进程崩溃自动拉起容器化部署Docker gunicorn配合 CI/CD 自动化6.1 gunicorn 启动命令与 worker 数量如果你的机器是 4 核 CPU建议配置gunicorn -w 4 -b 127.0.0.1:8000 manage:app。manage:app是 Flask 应用入口需要工厂函数返回 app 实例。如果是 Flask 2.x也可以直接用app变量。gunicorn manage:app -w 4 -k gthread --threads 8 --timeout 60 --log-level info --access-logfile logs/access.log --error-logfile logs/error.log这里-k gthread是关键因为支付回调里会用到 requests 请求第三方接口如果是同步 worker单个请求阻塞 2 秒就会拖慢整个 worker。使用 gthread 模式每个 worker 可以开多个线程适合 IO 密集型任务。但注意数据库连接池只能由一个线程独享所以建议把SQLALCHEMY_ENGINE_OPTIONS配置为{pool_size: 10, pool_recycle: 3600}。6.2 Nginx 反向代理与 HTTPS 强制Nginx 配置里最需要注意的是设置proxy_set_header否则 Flask 获取不到客户端的真实 IP同时支付回调需要公网可达必须开放 443 端口。server { listen 80; server_name your-domain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }配置完成后需要检查notify_url是否是 HTTPS 的地址。支付宝和微信都要求回调链接为 HTTPS如果是 HTTP 支付宝沙箱可以用http://但正式环境会校验域名是否备案以及是否是 HTTPS。本地调试时可以用ngrok或natapp做内网穿透但只建议测试阶段用生产不要依赖。6.3 支付回调排查三板斧上线后最容易出问题的就是异步通知我把常见异常和排查手段放在一张表里现象可能原因排查命令 / 方法支付宝一直WAIT_BUYER_PAY没有正确返回success查看 access.log 中回调请求的响应码打印响应体微信回调验签失败平台证书未及时更新写一个轮询接口定期刷新证书或使用Wechatpay-V3签名校验库订单已支付但用户网页显示待支付return_url里没有处理业务前端根据notify_url触发订单状态查询接口支付金额为 0.01 的测试单配置错误检查环境变量是否被覆盖使用flask config命令同步回调时二次刷新页面状态机不允许状态回退不参与状态更新只做展示最后再介绍一个调试技巧在 Flask 视图函数中加一个app.logger.info(payment_data)日志把回调的原始报文打印到文件。我通常在notify入口写一行日志将request.get_data()和请求头全部拉到本地。观察微信重试时的请求头Wechatpay-Timestamp如果连续几次时间戳间隔为 15 秒说明微信认为是失败的这时不要只盯业务逻辑先看 Nginx 的 access log 中返回的状态码是否为 200 或 302。确实需要返回{code:FAIL}时微信会按一定策略重试你可以主动返回失败观察重试但超过 5 次会被标记为失败单需要人工处理。本文还有配套的精品资源点击获取
返回列表