PHP内容付费系统实战:图文管理+微信支付闭环 简介这是一套基于PHP打造的内容付费系统源码主要面向个人站长、自媒体团队以及PHP开发者用于快速搭建支持图文付费阅读与微信支付收款的内容变现平台。系统覆盖内容发布、图文管理、付费解锁、订单支付等核心流程并集成微信支付接口可直接对接公众号或H5场景帮助中小站点低成本落地知识付费功能。压缩包共4605个文件约18.55MB源码包含1080个PHP业务文件、2137个PNG图片资源同时提供JavaScript、HTML、CSS、Less等前端文件以及SQL数据库脚本、Nginx配置、PEM证书等部署与安全相关材料目录结构较完整便于按模块定位修改包内还附带安装配置示例与数据库初始化脚本可辅助从零部署和本地调试。目前已有4470人学习下载适合具备一定PHP基础、想借鉴成熟业务逻辑或快速上线内容付费产品的开发者参考使用。1. 为什么先做「PHP内容付费系统」的图文内容 微信支付闭环图文内容付费听起来比直播打赏简单但真正把一个支持图文形式的内容发布、定价、售卖、权限控制串起来牵扯的东西比想象中多。最反直觉的一点支付本身不是难点难点是“谁能看正文”这个判断。很多独立开发者在网上找到PHP内容付费系统.zip解压以后发现带的是微信支付示例但订单状态、回调幂等、试读权限全都缺最后上线就漏付费。这套系统的最小闭环是三件事用PHP管理图文文章生成微信支付订单用支付回调开通阅读权限。适合接私活、做知识付费网站也适合在企业内网搭一个内容售卖模块。下面从数据表开始把这些点逐个落地。2. 图文内容付费系统的数据表设计与访问控制2.1 为什么图文内容要拆表而不是只加一个 price 字段最常被改坏的设计是在文章表里加 price 字段然后读取时判断用户有没有买。字段本身没问题问题是很多源码把完整正文直接存在 articles 表列表查询时 select * 把正文一起返回网络流量和内存都遭殃。更稳的拆分文章主表存标题、封面、价格、试读长度等展示信息另建 article_body 表存完整正文。读取文章列表时只查主表进入详情接口后再按订单状态决定返回试读内容还是全文。这样也可以避免全文被缓存在 CDN 或者页面源码里。订单和支付流水也要分开。orders 表记录用户与文章的购买关系payment 表记录每次支付尝试。微信支付异步通知可能会重复到达如果直接改 orders 表很容易把一笔订单重复更新而产生权限错乱。用一个单独的流水表记录通知编号配合幂等键是更稳的做法。2.2 建表 SQL 与字段参数说明下面这套表结构广泛用于 PHP 内容付费系统去掉业务无关字段后可以照抄CREATE TABLE article ( id int(11) unsigned NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 图文标题, cover varchar(500) DEFAULT NULL COMMENT 封面图URL, summary varchar(500) DEFAULT NULL COMMENT 列表摘要, price int(11) NOT NULL DEFAULT 0 COMMENT 售价单位分, is_free tinyint(1) NOT NULL DEFAULT 0 COMMENT 1免费, preview_length int(11) NOT NULL DEFAULT 0 COMMENT 试读字符数0表示不试读, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_status_price (status, price) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图文内容主表; CREATE TABLE article_body ( article_id int(11) unsigned NOT NULL, content longtext NOT NULL COMMENT 完整正文HTML, updated_at datetime DEFAULT NULL, PRIMARY KEY (article_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图文完整正文;CREATE TABLE orders ( id int(11) unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id int(11) unsigned NOT NULL, article_id int(11) unsigned NOT NULL, amount int(11) NOT NULL COMMENT 实付金额单位分, status varchar(16) NOT NULL DEFAULT pending COMMENT pending/paid/refunded/closed, transaction_id varchar(64) DEFAULT NULL COMMENT 微信支付单号, paid_at datetime DEFAULT NULL, expired_at datetime NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_article (user_id, article_id), KEY idx_status_article (status, article_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT内容订单表;字段说明字段含义常见误区priceint 类型单位分写成 decimal 元回调时单位换算出错preview_length试读字符数按数据库长度截断会把 HTML 标签截烂order_no业务订单号与 transaction_id 混用一个管内一个管外amount实付金额改成 float 容易丢失精度status订单状态没有 closed 状态导致超时单还能被回调注意务必给 order_no 加唯一索引并在写入时捕获 Duplicate entry。用户双击下单如果产生两个订单后面回调按订单号查询时会出现一个订单支付成功、另一个永远 pending 的脏数据。成本最低的做法是 createOrder 里捕获唯一索引冲突让第二个请求带出前一个订单号。2.3 内容访问控制先查订单再给正文支付完成后用户有没有权限不靠 session 里存 flag每次请求都要查订单表。原因很直接你无法控制在管理后台手工退款后用户的 session 会不会立即失效。订单状态才是唯一权威来源。public function detail(int $articleId): array { $article Article::query()-findOrFail($articleId); $user $this-auth-user(); $paid Order::query() -where(user_id, $user-id) -where(article_id, $articleId) -where(status, paid) -exists(); if ($article-is_free || $paid) { $body ArticleBody::query()-findOrFail($articleId); return [article $article, content $body-content]; } $preview $article-preview_length 0 ? mb_substr(strip_tags($body-content ?? ), 0, $article-preview_length) : ; return [ article $article, preview $preview, need_pay $article-price, ]; }这里用 strip_tags 删掉 HTML 标签后再按 preview_length 截取是为了避免截断在p中间导致页面布局错乱。mb_substr 以字符为单位不用 substr 按字节截中英文混排的标题不会出现半个字。订单查询走 (status, article_id) 联合索引一次索引查找就能判断权限不需要提前把文章状态缓存起来。从代码层面讲真正需要缓存的是整页静态化后的 HTML不是订单判断结果。缓存页面时要按照 user_id 区分否则前一个用户买到全文后一个免费用户从 CDN 拿到了同一份带正文的 HTML。很多本地测试正常、线上翻车的 PHP 内容付费系统问题都出在这里。3. 用 PHP 对接微信支付下单、回调与订单状态机3.1 下单前先创建本地订单再调统一下单接口不要一上来就调微信支付接口。先写死一个订单记录拿到订单号后再去微信下单这样回调回来时能在本地找到订单。否则用户支付成功回调里却查不到任何订单只能手工退款。public function createOrder(int $userId, int $articleId): array { $article Article::query()-findOrFail($articleId); if ($article-is_free || $article-price 0) { throw new \RuntimeException(免费内容无需下单); } $orderNo date(YmdHis) . str_pad($userId, 6, 0, STR_PAD_LEFT) . mt_rand(1000, 9999); try { $order Order::query()-create([ order_no $orderNo, user_id $userId, article_id $article-id, amount $article-price, status pending, expired_at date(Y-m-d H:i:s, time() 900), created_at date(Y-m-d H:i:s), ]); } catch (QueryException $e) { if ($e-getCode() ! 23000) { throw $e; } return $this-createOrder($userId, $articleId); } return $order-toArray(); }订单号用日期 用户 ID 随机数格式需要保持在 32 字符内。设计要点有三处。第一expired_at 设为当前时间加 900 秒等于 15 分钟比微信支付二维码的 2 小时有效时间短避免用户扫很久以前的码后再支付第二创建订单时 status 必须是 pending不能给一个“待支付”之外的状态否则回调更新逻辑要多处理一个分支第三order_no 唯一索引冲突后递归重试虽然理论上冲突概率很低但在秒杀这类场景里足以保住接口不报 500。3.2 微信支付统一下单参数Native 扫码与 JSAPI 的关键区别图文内容付费系统最常见的形态是电脑端扫码选 Native 支付嵌在公众号菜单或 H5 里选 JSAPI 并要带用户的 openid。两者的统一下单参数大部分相同区别是 trade_type 和附加参数。$params [ appid $config[appid], mchid $config[mchid], description mb_substr($article-title, 0, 30), out_trade_no $orderNo, notify_url $config[notify_url], trade_type NATIVE, amount [total $order[amount], currency CNY], ]; // 使用微信支付官方SDK发起请求 $result $wxClient-post(/v3/pay/transactions/native, [ json $params, ]); $codeUrl $result[code_url] ?? ;参数说明参数必填说明常见坑appid是公众号或开放平台 appid和商户号主体不一致时返回 PARAM_ERRORmchid是商户号APIv3 叫 mchid 不是 mch_id旧代码 v2 字段混用description是商品描述最长 127 字符传了 HTML 或 emoji 导致下单失败out_trade_no是商户订单号唯一长度超 32 或含特殊字符notify_url是回调地址公网 HTTPS填了带 query string 的地址导致验签路径变化amount.total是金额单位分整数传元或者小数报错trade_type是NATIVE/JSAPI/MWEB用 NATIVE 又想拿 openidNATIVE 支付返回的 code_url 是一个 weixin:// 开头的链接直接把二维码图片展示给用户。JSAPI 调用前需要拿到用户的 openid授权流程走公众号网页授权redirect_uri 需要和公众号后台配置的域名一致否则 http 或域名不匹配都会失败。这里介绍的是 APIv3很多 .zip 源码包里还带着 v2 的 md5 签名代码新商户尽量不用 v2微信支付新能力只会放在 v3 上。3.3 支付结果回调验签、金额核对、幂等更新支付回调是整个内容付费系统的命门。支付结果以回调为准不要依赖前端跳转页面的结果也不要只靠异步轮询。// notify.php 核心逻辑验签和报文解密交给SDK完成 $headers getallheaders(); $body file_get_contents(php://input); // 1. 验签并解密失败直接返回可识别的错误 try { $decrypted $wxClient-verifyAndDecrypt( $headers[Wechatpay-Timestamp], $headers[Wechatpay-Nonce], $headers[Wechatpay-Signature], $body ); } catch (\Exception $e) { Log::error(wx notify verify failed, [$e-getMessage(), $body]); http_response_code(401); exit; } // 2. 校验支付结果状态 if ($decrypted[trade_state] ! SUCCESS) { echo SUCCESS; exit; } // 3. 用订单号加行锁查订单防止并发回调 $order Order::query() -where(order_no, $decrypted[out_trade_no]) -lockForUpdate() -first(); if (!$order || $order-amount ! (int) $decrypted[amount][total]) { Log::error(wx notify order mismatch, $decrypted); echo FAIL; exit; } // 4. 只有 pending 才更新paid 直接返回 SUCCESS if ($order-status pending) { $order-status paid; $order-transaction_id $decrypted[transaction_id]; $order-paid_at date(Y-m-d H:i:s); $order-save(); // 把后续耗时任务投递到队列不在回调里同步执行 event(new OrderPaid($order-id)); } echo SUCCESS;这里有三个必须坚持的动作。第一回调里不能只验签名还要把回调报文里的实际金额和订单金额做全等比较否则遇到支付金额被篡改的测试或上游故障时能至少留下错误日志。第二拿到支付成功的单号后用 SELECT FOR UPDATE 给订单行加锁两个重复通知同时到达时第二个会等第一个提交后再判断天然串行化。第三回调返回的文本只能是 SUCCESS 或 FAIL 等约定值不能把 JSON 返给微信否则微信会按失败继续重试最多 25 次。另外说一下微信支付投诉回调。在商户平台「消费者投诉」服务里配置投诉通知回调 URL 后用户发起的每一笔投诉都会推送到这里。做内容付费系统时必须处理这个回调至少把投诉单号写日志并关联到对应订单方便客服退款。只做支付回调不做投诉回调用户付款后投诉无门商户号很快会被微信限制交易。3.4 订单状态机pending、paid、refunded、closedconst STATUS_PENDING pending; const STATUS_PAID paid; const STATUS_REFUNDED refunded; const STATUS_CLOSED closed;状态允许进入的状态触发动作pendingpaid / closed用户支付成功回调订单超时主动关单paidrefunded用户在订单页申请退款后台执行退款 APIrefunded不可逆关闭图文正文访问权限closed不可逆过期未支付不能再次回调保持状态机简单不要在状态里加“发货中、已读”这些跟支付无关的业务字段。内容付费系统的核心是支付状态决定访问权限退款后需要立刻让 hasPaid 返回 false。如果退款动作是手动处理的status 改 refunded 后还要清除用户对这篇文章的访问缓存否则用户依然能打开已购买的正文接口。4. 在 Nginx/PHP 环境部署内容付费系统.zip 的常见坑4.1 解压 zip 源码包后的环境自检拿到一个标题为 PHP 内容付费系统...zip 的源码包第一件事不是改数据库配置而是先在命令行确认 PHP 扩展是否齐全。图文内容系统一般还需要 mbstring 处理中文截断gd 库生成封面缩略图curl 和 openssl 请求微信支付接口。漏掉任何一个首页能打开但下单或回调时会报出诡异的错误。unzip content-pay.zip -d /var/www/html/content-pay cd /var/www/html/content-pay php -v php -m | grep -E curl|openssl|pdo_mysql|mbstring|gd php -r file_put_contents(runtime/test.log, ok);确认扩展后还要确认 runtime 目录的写权限。PHP-FPM 默认以 www-data 用户运行源码包在 Windows 下解压后常常把目录属主变成你自己。跑一下chown -R www-data:www-data runtime storage bootstrap否则 Laravel 这类框架的日志和缓存目录写不进去微信支付回调里连错误日志都记不下来。4.2 Nginx 伪静态与 HTTPS 配置微信支付统一下单要求回调地址是公网 HTTPS开发环境用 HTTP 也能调通但生产必须 HTTPS。Nginx 配置文件需要同时处理 pathinfo 和 PHP 解析两个点。大部分 zip 源码用的是单入口模式伪静态规则如下server { listen 443 ssl http2; server_name pay.example.com; root /var/www/html/content-pay/public; index index.php; ssl_certificate /etc/nginx/cert/pay.example.com.pem; ssl_certificate_key /etc/nginx/cert/pay.example.com.key; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~* \.(png|jpg|jpeg|gif|css|js)$ { expires 7d; access_log off; try_files $uri 404; } }重点看 try_files 那行它把不存在的路径交给 index.phpPHP 框架自己再根据路由解析 URL。如果你的源码不是单入口而是传统多 PHP 文件结构就不要套这条规则改成location / { }直接找静态文件。很多 .zip 自带说明里写的 rewrite 规则是 Apache 的搬到 Nginx 上忘了改语法结果除了首页全 404。fastcgi 的 sock 路径在 CentOS 和 Ubuntu 上不同CentOS 默认是/run/php-fpm/www.sockUbuntu 是/run/php/php-fpm.sock。写错时 Nginx 返回 502下一步应检查php-fpm -t和ls -l /run/php。4.3 微信支付参数配置与 PHP 错误处理签名失败排查在源码包里找 config/pay.php 或 .env把配置项对齐。APIv3 的配置项至少包含配置项示例说明appidwx1234567890公众号/开放平台mchid1900000001商户号v3 字段名api_v3_key32 位随机串解密回调报文merchant_certcert/apiclient_cert.pem商户 API 证书notify_urlhttps://pay.example.com/notify公网可访问常见的签名失败是私钥与证书不匹配。证书文件用绝对路径写不要写成 cert/apiclient_key.pem 然后以 public 为当前目录运行框架运行目录一变就找不到。另一个坑是服务器时间偏差APIv3 签名里带时间戳偏差超过 5 分钟会返回 CERT_MISMATCH 或 SIGN_ERROR。跑一下date看一眼差得远就同步系统时间。微信支付回调地址在商户平台配置的域名必须和证书域名一致而且不要把 notify_url 放到登录后才能访问的路由里。如果该路由有 CSRF 中间件回调会一直验签失败。调试时优先看 storage/logs 或 runtime/log 里的请求头和原始报文PHP 错误日志不要关掉。4.4 回调里不要做重活用队列处理后续业务微信支付对回调的响应时间要求很紧官方建议业务处理完立即返回。图文正文、订单通知、邮件、会员到期提醒这些都要放到队列里异步处理。在 PHP 里最常见的做法是 Redis Redis 队列或数据库队列表。// 支付回调里只投递事件 $order-status paid; $order-save(); event(new OrderPaid($order-id)); // 后台消费者进程里处理 Queue::after($order-id, function ($job) use ($order) { sendOrderNotify($order-id); markArticleAccessCached($order-user_id, $order-article_id); });如果写的是传统 PHP 没有框架的 queue 组件可以直接把待处理任务插到 jobs 表用 cron 每分钟扫一次。回调里一旦同步执行图片压缩或全文索引遇到慢 SQL 就会超过 5 秒前端用户扫完码页面一直不跳转还容易触发微信重复回调。5. 上线前必做的微信支付回调验证与图文防盗用技巧5.1 用真实一分钱订单走通全链路开发完成后建一篇价格设为 1 分的测试文章实际调一次微信支付再走退款接口把钱退回来。这是目前最稳的验证方式比自己伪造回调报文可靠因为回调里的签名、平台证书、时间戳都是真实环境数据。配置好 notify_url 后先用浏览器下单并扫码。支付成功后观察三件事notify 接口是否收到回调数据库订单状态是否变为 paid再次请求文章详情是否返回完整正文。任何一环缺失打开日志看支付结果。测试完成后在商户平台发起退款退款成功后再请求详情确认返回的是试读内容而不是全文。5.2 文字和图片的防盗用措施图文内容付费比视频容易盗版网页里全是 HTML 文本view-source 就能看到。常见做法是把正文渲染做成前后端分离接口前端拿到全文后加密存储但纯前端加密意义有限。更实际的手段是按用户订单 Id 生成带签名的图文访问地址签名过期时间为 15 分钟让盗版者抓到的直链很快失效。图片不能用源码里固定的绝对路径否则用户复制图片地址就能绕过付费。Nginx 下可以加防盗链规则location ~* \.(png|jpg|jpeg|gif|webp)$ { valid_referers none blocked server_names *.example.com; if ($invalid_referer) { return 403; } }注意 valid_referers 的写法none blocked 表示允许无 Referer 和带反代头部的请求防止微信支付内部访问图片时被误杀。图片防君子不防小人但能挡掉大部分复制 URL 的普通用户。5.3 zip 部署包交付前的三个检查如果把系统重新打包 zip 转给别人部署交付前先做三件事删掉 install、demo、test 目录去掉 .sql 之类的备份文件检查全项目有没有 eval、assert、create_function 等危险函数把 runtime 目录下生成的日志缓存清掉。grep -rEn eval\(|assert\(|create_function\(|base64_decode\( app/ --include*.php | grep -v /vendor/这条命令会列出所有可疑点。很多二次开发包被塞进 WebShell 后最常见的特征就是在某个看似正常的控制器里出现 base64_decode 加 eval。vendor 目录里的第三方库可以排除因为框架本身也可能有类似封装。最后确认一下 zip 包根目录不泄露 .env 配置数据库连接信息全部用环境变量占位。本文还有配套的精品资源点击获取