短剧SAAS系统源码拆解:付费解锁、支付回调与两级分佣
简介这份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}三段式code0表示成功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分支处理比如code1001余额不足时直接弹充值页比解析字符串友好得多。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短剧表; -- 解锁记录表episode_id0 表示整部解锁这是全系统最重要的约定 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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业务数据建库时指定 utf8mb4Redis6.x 以上登录态、回调锁、限次单机够用Nginx1.20 以上反向代理与静态资源root 必须指向 publicComposer2.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 7APP_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字样才算过关。现象二用户付了钱重新进播放页还是提示未解锁。这类问题九成出在整部解锁和单集解锁的标识没对齐。下单时type2表示整部如果回调里没把episode_id置为 0解锁记录就变成只解锁了当时那一集用户点下一集自然还是锁的。排查时直接查sc_unlock_record里这个用户的记录看episode_id是不是具体集号而不是 0是的话就说明这里漏了转换。修完记得把历史脏数据补一遍UPDATE之前先备份。现象三详情页打开要三四秒选集列表越往后越卡。原因是详情接口一次性把上百集全返回而且每一集都单独算了一次权限一百集就是上百次数据库查询。正常的做法是列表只返回基础字段解锁状态用一次WHERE user_id? AND drama_id?批量查出已解锁的集号集合在内存里做映射播放地址只在用户真正点开某一集时才生成。改造之后详情页接口的查询次数从上百降到个位数首屏时间能压到一秒以内。5.2 支付、部署与账号体系的三个坑现象四偶尔有订单支付成功了但解锁记录没写进去用户在客服那边炸锅。最常见的原因是回调接口里干了太多事——发短信、算佣金、写操作日志全塞在一起任何一步超时就导致整个回调返回失败而支付渠道重试几次之后就放弃了。正道是回调里只做两件事改订单状态、写解锁记录同一个事务里完成佣金走队列通知走异步全都从事务里摘出去。兜底再加一个每天跑的定时对账任务扫出已支付但无解锁记录的订单自动补单。**现象五部署到服务器后所有接口 404 或者本文还有配套的精品资源点击获取