☰
短剧SAAS系统源码拆解:付费解锁、支付回调与两级分佣
2026/10/27 4:43:17 网站建设 项目流程

简介:这份2024最新版视频短剧SAAS系统源码面向短剧创业者、影视小程序开发者及需要搭建内容付费平台的技术团队,提供可二次开发的多端整站方案。系统沿用SAAS架构,覆盖微信小程序与公众号H5双端,内置分销商等级与价格配置、二级分销、VIP会员、卡密兑换(VIP卡密、积分卡密、经销商卡密)等运营模块,并支持多云存储平台切换、批量导入与接口采集,便于按业务规模灵活组合。压缩包共494个文件,约4.3MB,以251个JavaScript、138个Vue及scss、json等前端工程文件为主,辅以png/jpg界面素材、nvue页面、sql脚本与md/docx搭建教程,目录结构较完整,适合对照梳理前后端模块、运营配置与部署思路,减少环境准备与功能串联时的试错成本。已有2042人浏览学习,可作为短剧平台原型参考、功能验证与二次开发底稿。

1. 短剧SAAS系统能干什么:三端一后台的真实分工

某开发者拆开这份视频短剧SAAS系统源码时以为它只是个播放页模板,跑起来才看见后台里挂着代理管理、分账比例、卡密兑换、内容审核七八个模块。这套影视短剧小程序源码本质上是个多租户内容分发加付费解锁系统:平台方管内容和结算,代理商管拉新和分成,终端用户只管充钱追剧。它适合两类人:一类要快速搭起短剧投放链路的运营团队,一类想拿付费墙和分账逻辑做参考实现的后端开发。后面按工程结构、启动步骤、核心付费链路、故障排查、二次改造几段拆,能照着跑通,也能照着改。

2. 技术栈与工程结构:后端、管理后台与小程序端的目录约定

这份源码解压后是三份独立工程:PHP 后端、Vue 管理后台、小程序端,另外还有一份database/install.sql初始数据。后端选了 ThinkPHP 6,跑在 PHP 8.0 上,MySQL 8.0 存业务数据,Redis 顶住登录态、支付回调和次数限制这类高频读写。选型谈不上激进,好处是部署门槛低,一台 2 核 4G 的云主机加对象存储就能撑住早期量级,二次开发时找人也容易。管理后台是 Vue3 加 Element Plus 的常规组合,小程序端走 uni-app,一套代码能同时出小程序和 H5 版本,运营临时要个 H5 投放页不用重写。

理解这套工程的关键,是先认清一次请求到底穿过哪几层。小程序点开某一集,请求经 Nginx 打到public/index.php,路由匹配到app/api/controller/Play.php,中间件先校验 token 和代理绑定关系,控制器只做参数收敛,真正的权限判定和地址签名交给UnlockService,最后落到模型层查库。这条链路要不要拆得这么细,取决于后面改不改得动:业务全堆在控制器里,加一个"看广告解锁"就得同时改五个接口,分层之后只动 service 一处。

2.1 后端分层:控制器只做参数校验,业务全在 service

目录结构直接反映作者的分层意图,admin和api是两个独立应用,共用common里的 service、model 和枚举。这种多应用模式比单应用加前缀路由好维护,后台接口和 C 端接口的中间件、鉴权方式本来就不一样,强行塞在一个控制器里迟早打架。

# 后端工程根目录 ├── app │ ├── admin # 平台管理后台接口 │ │ ├── controller # 短剧、剧集、订单、代理、结算 │ │ ├── validate │ │ └── middleware # 后台登录态、权限节点校验 │ ├── api # 小程序端接口 │ │ ├── controller │ │ │ ├── Drama.php # 列表、分类、详情、搜索 │ │ │ ├── Play.php # 播放地址与解锁判定 │ │ │ ├── Order.php # 下单、查单、支付回调 │ │ │ ├── User.php # 登录、观看历史、余额 │ │ │ └── Agent.php # 代理申请、团队、佣金 │ │ └── middleware │ │ ├── AuthToken.php # token 校验,注入 userId │ │ └── AgentBind.php # 邀请关系绑定 │ ├── common │ │ ├── service # 业务层,真正干活的地方 │ │ ├── model # 数据模型 │ │ ├── enum # 订单状态、解锁类型枚举 │ │ └── lib # 签名、对象存储、短信等工具 │ └── command # 定时任务:分账结算、关单、对账 ├── config ├── route │ ├── admin.php │ └── api.php ├── public │ └── index.php # 唯一入口,Web 根目录指向这里 ├── database │ └── install.sql ├── .env └── composer.json

控制器薄到什么程度,看Play.php就明白。它只负责三件事:把前端传来的字段收窄成白名单、校验必填和类型、把结果按统一格式吐回去。真正的解锁判定藏在 service 里,这样做的好处是后台预览播放和 C 端播放可以复用同一套逻辑,不用写两遍。

<?php // app/api/controller/Play.php namespace app\api\controller; use app\common\service\UnlockService; use think\facade\Validate; use think\Request; class Play extends Base { // 进入播放页前先问一次:这集要不要付费、当前用户有没有解锁 public function index(Request $request) { // 只收白名单字段,避免前端塞 drama_id 之类的旁路参数干扰判断 $param = $request->only(['episode_id'], 'post'); $validate = Validate::rule([ 'episode_id' => 'require|integer|gt:0', ]); if (!$validate->check($param)) { return $this->fail($validate->getError()); } // userId 由 AuthToken 中间件解析 token 后注入,控制器不自己解 token $userId = $request->userId ?? 0; $result = UnlockService::getInstance()->resolvePlayInfo( (int)$param['episode_id'], $userId ); return $this->success($result); } }

episode_id用integer|gt:0卡死,是因为这类系统最常见的越权尝试就是把 id 改成负数或字符串拼注入,参数层挡掉最省事。$request->userId允许为 0,对应未登录用户,service 里会走免费集分支,登录墙不该卡在接口入口,否则分享出去的免费集用户点开就是登录页,转化直接掉一半。

2.2 管理后台与小程序端:接口约定比代码本身更重要

后台和小程序端是分开打包的两份前端工程,但它们跟后端之间的契约只有一条:响应体永远是{code, message, data}三段式,code=0表示成功,401表示登录态失效,剩下业务错误码由后端自定义。这条约定看着朴素,落地时能省掉大量扯皮——前端不用猜哪次成功哪次失败,后端也不用为每个接口写一套格式。

小程序端目录按页面维度切,components里放跨页复用的解锁弹窗和选集抽屉,utils里是请求封装和登录态管理。真正需要注意的是请求封装这一层,token 存哪、失效怎么跳、错误怎么抛,全都集中在这里,后面所有页面都依赖它。

// utils/request.js const BASE_URL = 'https://api.example.com'; // 换成自己的后端域名,必须 https const TOKEN_KEY = 'drama_token'; export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json', // 没登录也照常带上空串,后端统一按未登录处理,省掉前端分支判断 'Authorization': uni.getStorageSync(TOKEN_KEY) || '' }, success: (res) => { const body = res.data || {}; if (body.code === 0) { resolve(body.data); return; } // 登录态失效统一踢回登录页,业务错误码交给调用方自己处理 if (body.code === 401) { uni.removeStorageSync(TOKEN_KEY); uni.navigateTo({ url: '/pages/login/login' }); } reject(body); }, fail: reject }); }); }

BASE_URL单独抽常量而不是从配置文件读,是因为小程序端打包后改配置要重新提审,把域名写死在代码里反而少一层不确定性;多环境切换在构建脚本里替换这个常量就行。reject(body)抛出的是完整响应体而不是Error对象,调用方可以按code分支处理,比如code=1001余额不足时直接弹充值页,比解析字符串友好得多。

2.3 数据表:两张核心表撑起整条付费链路

短剧系统的表不少,但真正决定付费逻辑对不对的只有两张:短剧主表和解锁记录表。前者定义"哪些集免费、整部多少钱",后者是判断用户有没有买过的唯一依据,其他表都是它的外围。

-- 短剧主表:免费策略、价格都放在这里,运营改价不用发版 CREATE TABLE `sc_drama` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `title` varchar(120) NOT NULL DEFAULT '' COMMENT '剧名', `cover` varchar(255) NOT NULL DEFAULT '' COMMENT '竖版封面', `category_id` int unsigned NOT NULL DEFAULT 0, `total_episode` int unsigned NOT NULL DEFAULT 0 COMMENT '总集数', `free_episode` int unsigned NOT NULL DEFAULT 3 COMMENT '前 N 集免费', `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '整部解锁价', `single_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '单集解锁价', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短剧表'; -- 解锁记录表:episode_id=0 表示整部解锁,这是全系统最重要的约定 CREATE TABLE `sc_unlock_record` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL, `drama_id` int unsigned NOT NULL, `episode_id` int unsigned NOT NULL DEFAULT 0 COMMENT '0 表示整部', `unlock_type` tinyint NOT NULL DEFAULT 1 COMMENT '1单集 2整部 3会员 4广告 5卡密', `order_no` varchar(32) NOT NULL DEFAULT '', `expire_time` int unsigned NOT NULL DEFAULT 0 COMMENT '会员类解锁的到期时间', `create_time` int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_episode` (`user_id`,`episode_id`), KEY `idx_user_drama` (`user_id`,`drama_id`), KEY `idx_order` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='解锁记录表';

uk_user_episode这个唯一索引不是可选项。支付回调会重复推送,定时补单任务也可能跑两遍,没有唯一索引兜底,同一集就会出现两条解锁记录,后面的佣金分账按记录条数算就会翻倍。加上之后,重复插入直接报错,service 里捕获吞掉即可。price和single_price用decimal(10,2)而不是float,是因为价格要参与佣金乘法和金额比对,浮点误差在分账场景里是实打实的事故来源。

3. 环境搭建与首跑:从建库到真机预览的完整步骤

拿到包之后不建议先翻代码,先把环境跑起来更重要。跑不起来的源码看再多也建立不了整体认知,跑起来之后哪怕只是改个剧名、换个封面,你对这套系统哪些地方能动、哪些地方动不得,感知会完全不一样。这一章按依赖、配置、启动、联调的顺序走一遍,中途容易卡住的地方会标出来。

3.1 依赖清单与 .env 关键配置

依赖版本对不上是首跑失败的头号原因,PHP 8.0 以下跑 ThinkPHP 6 的部分语法会直接报错,MySQL 5.7 在utf8mb4排序规则上也有差异。下面是这套工程实际能跑通的组合,版本别随意降。

组件建议版本作用备注
PHP8.0 / 8.1后端运行时需装 fileinfo、redis、gd 扩展
MySQL8.0业务数据建库时指定 utf8mb4
Redis6.x 以上登录态、回调锁、限次单机够用
Nginx1.20 以上反向代理与静态资源root 必须指向 public
Composer2.xPHP 依赖管理装依赖前先换国内源
Node.js16 / 18后台与小程序端构建别用 20 以上,部分插件不兼容

配置集中在根目录.env,改完不用动代码。下面几个字段是整个系统能不能正常收钱的关键,其他都能用默认值。

# .env APP_DEBUG = false APP_TRACE = false [database] TYPE = mysql HOSTNAME = 127.0.0.1 DATABASE = drama_saas USERNAME = drama PASSWORD = 换成自己的强密码 HOSTPORT = 3306 CHARSET = utf8mb4 PREFIX = sc_ [redis] HOST = 127.0.0.1 PORT = 6379 PASSWORD = SELECT = 0 [pay] # 支付回调必须公网可达,本地调试用内网穿透临时顶一下 NOTIFY_URL = https://api.example.com/api/order/notify APP_ID = 填支付渠道分配的应用标识 MCH_ID = 填商户号 MCH_KEY = 填密钥,只放服务端,别出现在任何前端工程里 [site] # 两级分销写得死一点,层级再多支付渠道风控会盯上 AGENT_LEVEL = 2 # 佣金冻结天数,留出退款窗口 SETTLE_DELAY_DAY = 7

APP_DEBUG线上必须给false,它一旦为true,报错页会把数据库账号、目录路径、甚至.env里的片段直接打到浏览器上。SETTLE_DELAY_DAY是很实用的一个参数,佣金不是支付成功立刻可提现,先冻结 7 天,用户退款时才有回旋余地,这个值调小等于把风险扛到自己身上。

3.2 建库、导入初始数据与后端启动

按顺序执行,每一步都有它的意义,跳步容易出现"接口全部 500 但看不出原因"的情况。

# 1. 安装 PHP 依赖,--no-dev 跳过测试和调试包,线上也用这套 composer install --no-dev # 2. 建库,字符集必须显式指定,否则中文剧名会变问号 mysql -uroot -p -e "CREATE DATABASE drama_saas DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 3. 导入初始结构,install.sql 里通常还带一份演示数据 mysql -uroot -p drama_saas < database/install.sql # 4. 运行时目录和上传目录要给写权限,否则日志写不进去,报错也没痕迹 chmod -R 775 runtime public/uploads # 5. 开发阶段先用内置服务器验证,线上必须换 Nginx php think run -H 0.0.0.0 -p 8000

内置服务器能跑通只说明代码没问题,线上部署还有一道坎。Nginx 的root一定要指到public子目录,指到项目根目录,/index.php就找不到,所有接口一起 404。

server { listen 80; server_name api.example.com; root /www/drama/public; # 关键:指向 public,不是项目根 index index.php; location / { try_files $uri $uri/ /index.php?s=$uri&$args; } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.0-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # 视频文件不走后端,一律由对象存储和 CDN 直出 location ~* \.(mp4|m3u8|ts|key)$ { deny all; } }

最后那段deny all值得单独说一句:很多人图省事把视频扔在public/uploads下让 Nginx 直接返回,结果地址被扒出来之后整部剧白送。视频放对象存储、接口只发签名地址,是这类系统唯一稳妥的做法,这条规则等于从 Web 层把后路堵死。

3.3 小程序端配置与真机预览

小程序端构建之前,manifest.json里要填应用标识,request和downloadFile的合法域名要配成后端域名,且必须是备案过的 https 域名。本地开发可以在开发者工具里勾选"不校验合法域名"绕过,但真机预览绕不过去,这是新手最容易卡住的一步——工具里一切正常,扫码到手机上全部网络请求失败。

{ "pages": [ "pages/index/index", "pages/drama/detail", "pages/play/play", "pages/mine/mine" ], "window": { "navigationBarTitleText": "短剧", "navigationStyle": "custom" }, "networkTimeout": { "request": 10000, "downloadFile": 20000 } }

navigationStyle设成custom是为了让播放页做成全屏沉浸式,返回按钮和剧名由页面自己画。downloadFile超时给到 20 秒,是因为拉取播放列表和封面图可能并发,10 秒在弱网下经常不够。真机调试阶段建议先只测一条链路:首页列表 → 详情页 → 点某一集 → 命中免费集直接播,这条通了再测付费分支,一次测全量会分不清是播放器的问题还是接口的问题。

4. 核心模块拆解:付费解锁、支付回调与分佣结算

整套系统的商业价值全压在三个 service 上:UnlockService决定谁能看,OrderService决定钱有没有到账,CommissionService决定钱怎么分。这三块写扎实了,换成其他内容形态(比如付费小说、付费课程)也照样能用;写虚了,运营跑一个月就得推倒重来。下面按调用顺序拆。

4.1 解锁判定:先算权限,再发播放地址

这段逻辑的判断顺序不是随手排的,免费集优先是因为它命中率最高,会员和整部解锁放后面,能把大部分请求挡在几次简单的数据库查询之内。更重要的原则是:未解锁时接口绝对不返回真实播放地址,前端拿不到地址,就没有绕过付费的空间。

<?php // app/common/service/UnlockService.php namespace app\common\service; use app\common\model\Drama; use app\common\model\Episode; use app\common\model\UnlockRecord; use app\common\model\UserVip; class UnlockService extends BaseService { /** * 返回播放所需的全部信息:地址、是否锁定、可选解锁方式 */ public function resolvePlayInfo(int $episodeId, int $userId): array { $episode = Episode::find($episodeId); if (!$episode || $episode['status'] != 1) { throw new \Exception('剧集不存在或已下架'); } $drama = Drama::find($episode['drama_id']); // 1. 免费集直接放行,第几集之前免费写死在短剧表,运营可改 if ($episode['sort'] <= $drama['free_episode']) { return $this->buildPlayData($episode, 'free'); } // 2. 整部解锁优先判定,命中一次就不用再查单集和会员 $whole = UnlockRecord::where('user_id', $userId) ->where('drama_id', $drama['id']) ->where('episode_id', 0) ->find(); if ($whole && ($whole['expire_time'] == 0 || $whole['expire_time'] > time())) { return $this->buildPlayData($episode, 'drama'); } // 3. 单集解锁 $single = UnlockRecord::where('user_id', $userId) ->where('episode_id', $episodeId) ->find(); if ($single) { return $this->buildPlayData($episode, 'episode'); } // 4. 会员期内全站可看,这一层决定会员卡卖不卖得动 $vip = UserVip::where('user_id', $userId)->find(); if ($vip && $vip['expire_time'] > time()) { return $this->buildPlayData($episode, 'vip'); } // 5. 全部未命中,只返回锁定态和价格,绝不带播放地址 return [ 'locked' => true, 'episode_id' => $episodeId, 'drama_id' => $drama['id'], 'pay_options' => [ ['type' => 1, 'price' => $drama['single_price'], 'desc' => '解锁本集'], ['type' => 2, 'price' => $drama['price'], 'desc' => '解锁全集'], ['type' => 3, 'price' => $this->getVipPrice(), 'desc' => '开通会员'], ], ]; } // 真实地址在这一步才做签名,有效期 30 分钟,过期需重新请求 private function buildPlayData(array $episode, string $source): array { $url = \app\common\lib\Storage::signUrl($episode['video_url'], 1800); return [ 'locked' => false, 'episode_id' => $episode['id'], 'play_url' => $url, 'expires_in' => 1800, 'source' => $source, ]; } }

$source这个字段看着多余,实际排查问题时很有用:用户投诉"我明明买了还是看不了",后台一看source是free,说明命中的是免费集而不是他的购买记录,问题立刻定位到单集和整部的解锁类型上。签名有效期 1800 秒是个折中值,太短用户看到一半地址失效要重进,太长等于给了盗链者可乘之机。

4.2 下单与支付回调:幂等是唯一的后悔药

支付回调是整个系统最容易出错的地方,没有之一。支付渠道的重试机制意味着同一个订单可能被通知三五次,网络抖动还会造成通知晚到、乱序。这套代码用了三层防护:Redis 分布式锁挡住并发重复通知、订单状态机挡住已处理的通知、数据库唯一索引挡住极端情况下的漏网之鱼。

<?php // app/common/service/OrderService.php namespace app\common\service; use app\common\model\Order; use app\common\model\UnlockRecord; use think\facade\Db; use think\facade\Cache; class OrderService extends BaseService { // 下单:同一用户对同一集,两小时内的未支付订单直接复用 public function create(int $userId, int $dramaId, int $episodeId, int $type) { $exists = Order::where('user_id', $userId) ->where('episode_id', $episodeId) ->where('status', 0) ->where('create_time', '>', time() - 7200) ->find(); if ($exists) { return $exists; } $orderNo = date('YmdHis') . $userId . mt_rand(1000, 9999); return Order::create([ 'order_no' => $orderNo, 'user_id' => $userId, 'drama_id' => $dramaId, 'episode_id' => $episodeId, 'type' => $type, 'amount' => $this->calcPrice($dramaId, $episodeId, $type), 'status' => 0, // 0待支付 1已支付 2已关闭 3已退款 'create_time' => time(), ]); } // 支付回调:锁 + 事务 + 金额比对,一个都不能少 public function notify(array $payload): bool { $orderNo = $payload['out_trade_no'] ?? ''; if (!$orderNo) { return false; } $lockKey = 'pay_notify:' . $orderNo; // 5 秒锁,抢不到说明另一次通知正在处理,返回 true 让渠道别再推 if (!Cache::store('redis')->set($lockKey, 1, ['nx', 'ex' => 5])) { return true; } Db::startTrans(); try { // 行锁,防止两个请求同时读到待支付状态 $order = Order::where('order_no', $orderNo)->lock(true)->find(); if (!$order) { throw new \Exception('订单不存在'); } if ($order['status'] == 1) { Db::commit(); return true; // 已处理过,重复通知直接吞掉 } if ($order['status'] != 0) { throw new \Exception('订单状态非法'); } // 金额必须比对,回调参数是可以被伪造的 if (bccomp((string)$order['amount'], (string)($payload['total_fee'] / 100), 2) !== 0) { throw new \Exception('金额不一致'); } $order->save(['status' => 1, 'pay_time' => time()]); // 改状态和写解锁必须同一事务,否则会出现钱到了但没解锁 $new = UnlockRecord::create([ 'user_id' => $order['user_id'], 'drama_id' => $order['drama_id'], 'episode_id' => $order['type'] == 2 ? 0 : $order['episode_id'], 'unlock_type' => $order['type'], 'order_no' => $orderNo, 'create_time' => time(), ]); Db::commit(); // 分佣在事务外异步触发,别让它拖慢回调响应 app('queue')->push(CommissionService::class, $orderNo); return true; } catch (\Throwable $e) { Db::rollback(); trace('pay notify fail: ' . $e->getMessage(), 'error'); return false; } } }

type == 2时把episode_id写成 0,是整部解锁的唯一标识,跟前面UnlockService的查询条件必须严格对齐。bccomp比较时两边都转成字符串,total_fee从分转元除以 100 之后会变成浮点,直接==比较迟早出岔子。异常捕获之后返回 false 而不是抛出,是为了让支付渠道走它的重试流程,同时把错误写进日志留档,方便事后对账。

4.3 分销佣金:两级分账怎么算、什么时候落库

代理分佣是这类 SAAS 系统的收入放大器,也是最容易算错的地方。这套实现只做两级,一级从订单金额里按比例抽,二级从一级的基数里再抽,层级再往上加,不只是支付渠道风控的问题,人工对账都能把人熬垮。

<?php // app/common/service/CommissionService.php namespace app\common\service; use app\common\model\Agent; use app\common\model\Commission; use app\common\model\Order; use think\facade\Db; class CommissionService extends BaseService { // 只对内容解锁类订单分佣,充值余额是否分佣交给运营配置 public function settle(string $orderNo): void { $order = Order::where('order_no', $orderNo)->find(); if (!$order || $order['status'] != 1 || $order['settled'] == 1) { return; // 未支付或已结算,直接跳过,防重复分账 } $agent = Agent::where('user_id', $order['user_id'])->find(); if (!$agent) { return; // 自然流量没有上级,不分佣 } Db::startTrans(); try { $level = 1; $current = $agent; $base = $order['amount']; while ($current && $level <= 2) { $rate = $level == 1 ? $this->getRate('level1') : $this->getRate('level2'); // bcmul 用字符串运算,浮点乘法在分账里误差会被放大 $amount = bcmul((string)$base, (string)$rate, 2); if (bccomp($amount, '0.01', 2) < 0) { break; // 不足一分不落账,否则佣金表会被碎片记录撑爆 } Commission::create([ 'order_no' => $orderNo, 'agent_id' => $current['id'], 'level' => $level, 'rate' => $rate, 'amount' => $amount, 'status' => 0, // 0冻结中 1可提现 2已提现 'create_time' => time(), ]); $current = Agent::where('user_id', $current['parent_uid'])->find(); $level++; } $order->save(['settled' => 1]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); trace('commission settle fail: ' . $e->getMessage(), 'error'); } } }

佣金记录的初始状态是"冻结中",靠一个定时任务在冻结期满(对应.env里的SETTLE_DELAY_DAY)后批量改成"可提现"。这个设计看着多了一步,却挡住了退款带来的负余额——用户付完钱拿了佣金,第二天申请退款,如果佣金已经能提现,平台只能自己贴钱。settled字段打在订单上而不是靠佣金表反查,是为了让重复调用settle时能在最外层就返回。

5. 常见问题排查:上线后最容易翻车的六个点

这套系统在测试环境跑得再顺,上线第一个月总会遇到几个反复出现的故障。下面六条是按发生频率排的,每条都按现象、原因、解决三段写,遇到了直接对照查。

5.1 播放与解锁链路的三个坑

现象一:付费集能被直接抓包拿到 m3u8 地址,用户不付钱也能看。原因基本只有一个——接口在锁定态下依然返回了video_url,或者前端把地址缓存进了本地。解决方式是把播放地址的生成收进buildPlayData一个方法里,锁定态分支根本不构造这个字段;同时在对象存储侧开启 referer 白名单和 URL 签名,签名有效期压到 30 分钟以内。改完之后拿未付费账号抓一次包验证,接口返回里搜不到任何m3u8、mp4字样才算过关。

现象二:用户付了钱,重新进播放页还是提示未解锁。这类问题九成出在整部解锁和单集解锁的标识没对齐。下单时type=2表示整部,如果回调里没把episode_id置为 0,解锁记录就变成"只解锁了当时那一集",用户点下一集自然还是锁的。排查时直接查sc_unlock_record里这个用户的记录,看episode_id是不是具体集号而不是 0,是的话就说明这里漏了转换。修完记得把历史脏数据补一遍,UPDATE之前先备份。

现象三:详情页打开要三四秒,选集列表越往后越卡。原因是详情接口一次性把上百集全返回,而且每一集都单独算了一次权限,一百集就是上百次数据库查询。正常的做法是列表只返回基础字段,解锁状态用一次WHERE user_id=? AND drama_id=?批量查出已解锁的集号集合,在内存里做映射;播放地址只在用户真正点开某一集时才生成。改造之后详情页接口的查询次数从上百降到个位数,首屏时间能压到一秒以内。

5.2 支付、部署与账号体系的三个坑

现象四:偶尔有订单支付成功了,但解锁记录没写进去,用户在客服那边炸锅。最常见的原因是回调接口里干了太多事——发短信、算佣金、写操作日志全塞在一起,任何一步超时就导致整个回调返回失败,而支付渠道重试几次之后就放弃了。正道是回调里只做两件事:改订单状态、写解锁记录,同一个事务里完成;佣金走队列,通知走异步,全都从事务里摘出去。兜底再加一个每天跑的定时对账任务,扫出"已支付但无解锁记录"的订单自动补单。

**现象五:部署到服务器后所有接口 404 或者

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询