WebsitePusher 服务端过滤 演示
集成到后端API可以将注册用户推送的消息ID记录到Redis,已登录请求再根据filter校验合法性,例如后端推送例如评论系统历史数据推送给所有人。如果不需要 已登录校验、私信、过滤 等功能,并不需要后端参与,查看index.html足够了。
您的业务后端,后端调用API接口后需要将messageIdList写入redis缓存,方便在filter 接口校验缓存,其他人可看到此条弹幕。
function sendExample() {
var sample = {info: "前端发送的第一条弹幕仅本人可见", img: "https://www.phprm.com/push/h5/static/avatar/danMu.png"};
WebsitePusher.sendDanMu(sample);
}
2. 前端渲染完成后调用 sendCallback(item, settings) 弹幕数据回传到后端 server.php?action=send:
sendCallback: function (item, settings) {
// sendDanMu 仅本地滚动, 这里异步请求后端 trigger 接口实现云端分发
console && console.log("sendCallback", item);
$.getJSON('https://ws.phprm.com/api/server.php', {type: 'send', channelCode: settings.channelCode, head: "后端已审核弹幕推送给所有人可见", url: window.location.href}, function(resp) {
if (resp.code != 0 || !resp.data) {
return;
}
var resultMap = resp.data;
console && console.log(resultMap);
var messageIdList = resp.data["messageIdList"] || [];
if (messageIdList) {
// 加入 settings.messageIdList 将跳过 filter 检测
for (var i = 0; i < messageIdList.length; i++) {
settings.messageIdList.push(messageIdList[i]);
}
}
});
}
server 端(php)
set($redisPrefix . "messageId:". $messageId, "1", 600);缓存起来到filter里验证是否由本系统触发
foreach($jsonObject["data"]["messageIdList"] as $messageId) {
$kv -> set($redisPrefix . "messageId:". $messageId, $channelCode, 600);
}
// 前端将messageIdList加入this.settings.messageIdList跳过filter过滤, 不是当前用户触发的弹幕不会展示
$resultModel["data"] = array("messageIdList" => $jsonObject["data"]["messageIdList"]);
echo cross_domain(json_encode($resultModel));
exit();
}
/**
* 弹幕过滤器
*/
danMuFilter: function (item) {
console && console.log("danMuFilter", item);
var _this = this;
// 后端异步方式校验, 过滤弹幕和查询点赞次数
$.getJSON('https://ws.phprm.com/api/server.php', {type: 'filter', messageId: item.id, bizId: "1", url: window.location.href}, function(resp) {
if(resp.code != 0 || ! resp.data){
return;
}
var resultMap = resp.data;
// item.id即推送API返回的messageIdList里的第一个消息ID, 后端每次发送弹幕可关联业务评论ID与消息ID, 反查评论ID点赞数
item.num = resultMap["likeCount"] ? resultMap["likeCount"] : 0;
// 使用默认过滤器回调渲染弹幕
return _this.danMuFilterCallback(item, function (dom) {
_this.danMuPopCallback(dom, item);
});
});
}
server 端 (php)
if($type == "filter") {
// 除了AI弹幕,所有用户发送的弹幕都会走到过滤逻辑
$messageId = isset($_GET["messageId"]) ? $_GET["messageId"] : 0;
$bizId = isset($_GET["bizId"]) ? $_GET["bizId"] : "";
if(empty($messageId)) {
$resultModel["code"] = -1;
$resultModel["message"] = "messageId不正确";
echo cross_domain(json_encode($resultModel));
exit();
}
$messageJson = $kv->get($redisPrefix . "messageId:" . $messageId);
if(empty($messageJson)) {
// 非本系统触发或者缓存已过期
$resultModel["code"] = -1;
$resultModel["message"] = "弹幕有效期已过期";
echo cross_domain(json_encode($resultModel));
exit();
}
$bizCacheJson = $kv->get($redisPrefix . "biz:" . $bizId);
$bizObject = json_decode($bizCacheJson, true);
if(empty($bizObject)) {
// 业务(例如评论)应当从数据库查询, 未查到不展示弹幕或者将弹幕数据持久化到
$resultModel["code"] = -1;
$resultModel["message"] = "未查询到有效业务数据";
echo cross_domain(json_encode($resultModel));
exit();
}
// 如果danMuSetting.like=1, 需要从数据库中查询出bizId对应的点赞数likeCount
$resultModel["data"] = $bizObject;
echo cross_domain(json_encode($resultModel));
exit();
}
/**
* 弹幕启动回调判断是否初始化弹幕
*/
danMuInitCallback: function (settings) {
var _this = this;
// 这里在后端调用send API, 当设置return false即使用户配置和url参数开启弹幕依然无法启用弹幕
console && console.log("danMuInitCallback settings:", settings);
var randomUserId = Math.round(Math.round(Math.random() * 100000) / 1000);
console && console.log("randomUserId", randomUserId);
if (settings.channelCode) {
$.getJSON('https://ws.phprm.com/api/server.php', {type: 'init', channelCode: settings.channelCode, bizId: "1", url: window.location.href, nickname: "测试用户"+randomUserId}, function(resp) {
if(resp.code != 0 || ! resp.data){
return;
}
var resultMap = resp.data;
console && console.log(resultMap);
var messageIdList = resp.data["messageIdList"] || [];
if (messageIdList) {
// 加入_this.messageIdList.push将跳过filter检测
for(var i=0; i < resp.data["messageIdList"].length; i++) {
settings.messageIdList.push(resp.data["messageIdList"][i]);
}
}
});
}
// danMuInitCallback必须return true才能正常初始化弹幕, return false前建议调用WebsitePusher.destroy()(业务方自行存取弹幕开关配置在这里return true/false)
return true;
}
server 端 (php)
if($type == "init") {
// 启动时可以将业务id和channelCode进行一对一绑定
$bizId = isset($_GET["bizId"]) ? $_GET["bizId"] : "";
$channelCode = isset($_GET["channelCode"]) ? $_GET["channelCode"] : "";
$nickname = isset($_GET["nickname"]) ? $_GET["nickname"] : "你";
if(empty($channelCode)) {
$resultModel["code"] = -1;
$resultModel["message"] = "通道码不正确";
echo cross_domain(json_encode($resultModel));
exit();
}
// 可以给弹幕带上自定义链接, 但需要满足每30秒内只有一个带连接的url, 这里加上随机数可以跳过限制
$url = isset($_GET["url"]) ? $_GET["url"] : $_SERVER["HTTP_REFERER"];
// 自定义发布人头像和欢迎语
$avatar = "https://www.phprm.com/push/h5/static/avatar/danMu.png";
// 查询数据库中的评论放到线程池中逐条通过推送API发布
$bizCacheJson = $kv -> get($redisPrefix . "biz:". $bizId);
if(empty($bizCacheJson)) {
$ready = array(
"bizId" => $bizId,
"likeCount" => 99
);
} else {
$ready = json_decode($bizCacheJson, true);
}
// 弹幕通道已经使用预设通道码加签, 需要额外传timestamp、nonce、sign三个参数, $signSecret请查看http://push.phprm.com/config.html
$signSecret = "3f506231f2bbdd44026990080d430abe";
$timestamp = getMillisecond();
$nonce = md5(time());
$rawText = "${timestamp}#${signSecret}#${nonce}";
$sign = md5($rawText);
// 独享模式: 仅当前用户可见, 发送完私信不存redis, 让前端存messageIdList缓存
$time = explode(" ", microtime());
$url = strpos($url, "?") === false ? $url . "?time=".$time[0] : $url . "&time=".$time[0];
$sendUrl = "https://www.phprm.com/services/push/trigger/${channelCode}?delayMilliseconds=5000&url=".rawurlencode($url)."&avatar=".rawurlencode($avatar)."&head=".urlencode($nickname.", 你有一条私信仅自己可见"). "×tamp=".$timestamp. "&nonce=".$nonce. "&sign=".$sign;
$json = file_get_contents($sendUrl);
$jsonObject = json_decode($json, true);
// 返回给前端的白名单消息ID
$messageIdList = empty($jsonObject["data"]["messageIdList"]) ? array() : $jsonObject["data"]["messageIdList"];
$timestamp = getMillisecond();
$nonce = md5(time());
$rawText = "${timestamp}#${signSecret}#${nonce}";
$sign = md5($rawText);
// 调用推送API(延迟5秒后才推送到弹幕,防止用户本地弹幕还未建立连接成功就发送弹幕), 还需注意自动创建的弹幕通道是否开启了签名: http://push.phprm.com/api.html#sign
$time = explode(" ", microtime());
$url = strpos($url, "?") === false ? $url . "?time=".$time[0] : $url . "&time=".$time[0];
$nextUrl = "https://www.phprm.com/services/push/trigger/${channelCode}?delayMilliseconds=5000&url=".rawurlencode($url)."&avatar=".rawurlencode($avatar)."&head=".urlencode("大家欢迎".$nickname.", 一大波弹幕来袭"). "×tamp=".$timestamp. "&nonce=".$nonce. "&sign=".$sign;
$json = file_get_contents($nextUrl);
$jsonObject = json_decode($json, true);
if(empty($jsonObject) || empty($jsonObject["data"]["messageIdList"])) {
// 公开弹幕推送失败
$resultModel["code"] = -1;
$resultModel["message"] = "公开弹幕推送失败";
$resultModel["data"] = $json;
echo cross_domain(json_encode($resultModel));
exit();
}
// 共享模式: 后面的所有消息公开可见, 发送完公开信需要存redis: $kv -> set($redisPrefix . "messageId:". $messageId, "1", 600);缓存起来到filter里验证是否由本系统触发
foreach($jsonObject["data"]["messageIdList"] as $messageId) {
// 存什么不重要, 重要的是type=filter能校验通过, bizId可以存这里, 后端可以在filter里从messageId缓存读出关联的bizId
$kv -> set($redisPrefix . "messageId:". $messageId, $bizId, 600);
}
// 可选: 缓存每个业务的点赞数(仅用于示例filer查询到点赞数)
$kv -> set($redisPrefix . "biz:". $ready["bizId"], json_encode($ready), 86400);
// 前端将messageIdList加入this.settings.messageIdList跳过filter过滤, 不是当前用户触发的弹幕不会展示
$resultModel["data"] = array("messageIdList" => $messageIdList);
echo cross_domain(json_encode($resultModel));
exit();
}
/**
* 弹幕开始滚动前绑定点击事件
*/
danMuPopCallback: function (dom, item) {
console.log("danMuPopCallback", item);
// 展示弹幕后给item绑定事件 (barrager.js 通过 .call(div_barrager[0], barrage, action) 触发, this 指向弹幕DOM)
item.onAction = function(barrage, action) {
// 这里可以根据action对弹幕进行操作
// - action: 'close' 关闭 | 'like' 点赞 | 'unlike' 取消点赞
console && console.log("danMuPopCallback弹幕被点击:", action, "内容:", barrage.info);
$.getJSON('https://ws.phprm.com/api/server.php', {type: 'click', action: action, messageId: item.id, bizId: "1"}, function(resp) {
if(resp.code != 0 || ! resp.data){
return;
}
console && console.log("danMuPopCallback click后端处理逻辑:", resp);
});
};
}
server 端 (php)
if($type == "click") {
// click操作类型
$action = isset($_GET["action"]) ? $_GET["action"] : "like";
$bizId = isset($_GET["bizId"]) ? $_GET["bizId"] : "";
if(empty($bizId)) {
$resultModel["code"] = -1;
$resultModel["message"] = "业务ID不正确";
echo cross_domain(json_encode($resultModel));
exit();
}
// 点赞操作
if ($action == "like") {
$bizCacheJson = $kv->get($redisPrefix . "biz:" . $bizId);
$bizObject = json_decode($bizCacheJson, true);
if(empty($bizObject)) {
// 业务(例如评论)应当从数据库查询, 未查到不更新点赞数
$resultModel["code"] = -1;
$resultModel["message"] = "未查询到有效业务数据";
echo cross_domain(json_encode($resultModel));
exit();
}
// 如果like:1, 将业务点击次数+1
$bizObject["likeCount"] = $bizObject["likeCount"] + 1;
// 更新业务数据里的点赞次数
$kv -> set($redisPrefix . "biz:". $bizObject["bizId"], json_encode($bizObject), 86400);
$resultModel["data"] = $bizObject;
}
// TODO: close(举报不良弹幕)关闭事件
echo cross_domain(json_encode($resultModel));
exit();
}
/**
* 配置aiDanMu>0即可
*/
var settings = {
appName: "AI弹幕",
cssSite: "static/css/",
jsSite: "static/js",
danMu: 1,
channelCode: "4d05f4abdb0a0c2a0269900809946903",
device: "81614", // 指定业务后端注册用户唯一UID,将从channelMemberUrl自动换取uid
hidePanel: 1,
hideDialog: 1,
danMuSetting: {
aiDanMu: 60, // 每60秒间隔轮询一次后端, 当后端异常或者aiRestAmount<=0将终止轮询
aiDanMuUrl: "server.php?type=checkAiDanMu", // 自定义AI弹幕的后端地址即可
hideIcon: 0,
opacity: 50,
size: 20,
speed: 18,
like: 1,
close: 0,
color: "#fff"
}
}
// 如果需要server.php?type=checkAiDanMu接收到更多参数,可重写settings.checkAiDanMu方法
server 端 (php)
if($type == "checkAiDanMu") {
$channelCode = isset($_GET["channelCode"]) ? $_GET["channelCode"] : "";
$pollCount = isset($_GET["pollCount"]) ? (int) $_GET["pollCount"] : 0;
if ($pollCount > 1000) {
$resultModel["message"] = "全村的笋都被你夺完了, 刷新完页面再试吧, pollCount=".$pollCount;
// 炒股1000次不允许重试
$resultModel["data"] = array("aiRestAmount" => 0, "messageIdList" => array());
echo cross_domain(json_encode($resultModel));
exit();
}
// 慎重: 这里需要使用redis对TPS进行流控, 否则弹幕数将与请求同一个接口的人数成正比, 影响所有打开同一个页面体验, 可以给$channelCode加一个setNx快速失败的检测锁(有效期应该大于本脚本执行耗时小于js里面aiDanMu传参)
$setFlag = $kv -> setnx($redisPrefix . "lock:" . $channelCode, json_encode($_GET), 2);
if (!$setFlag) {
$resultModel["code"] = 0;
$resultModel["message"] = "加锁失败, 有人已经触发了AI弹幕, pollCount=".$pollCount;
// 查询计数器剩余有效时间
$ttl = $kv -> ttl($redisPrefix . "lock:" . $channelCode);
if ($ttl <= 0) {
$kv -> delete($redisPrefix . "lock:" . $channelCode);
}
// 加锁失败允许下次重试需要返回大于0的aiRestAmount
$resultModel["data"] = array("aiRestAmount" => 1, "messageIdList" => array());
echo cross_domain(json_encode($resultModel));
exit();
}
// 限流: 每1分钟可调用5次
$aiRestAmountKey = $redisPrefix . "rest:" . $channelCode;
$kv -> setnx($aiRestAmountKey, 5, 60);
// 每次访问-1
$aiRestAmount = $kv -> decr($aiRestAmountKey);
if ($aiRestAmount <= 0) {
// 查询计数器剩余有效时间
$ttl = $kv -> ttl($aiRestAmountKey);
if ($ttl <= 0) {
$kv -> delete($aiRestAmountKey);
}
$resultModel["message"] = "当前AI弹幕额度已耗尽, 请".$ttl."秒后再试, pollCount=".$pollCount;
// 正式环境,需要产品/开发者自行决定aiRestAmount返回0还是1, 如果返回0前端将终止轮询
$resultModel["data"] = array("aiRestAmount" => 1, "messageIdList" => array());
echo cross_domain(json_encode($resultModel));
exit();
}
// 业务执行: 这里可以拿着$title去请求AI接口, 拿到返回值拼接成head调用推送API
$aiUrl = "https://api.moonshot.cn/anthropic/v1/messages";
$aiUrl = "https://api.deepseek.com/";
//$chatResult = requestAI($aiUrl, 'sk-fake', 'kimi-k3');
$chatResult = requestAI($aiUrl, 'sk-fake', 'deepseek-v4-flash');
// 降级为其他弹幕内容
$head = "你正访问的网页标题是: ".$_GET["title"];
if (!empty($chatResult['choices'][0]['message']['content']) || !empty($chatResult['choices'][0]['message']['reasoning_content'])) {
$answer = !empty($chatResult['choices'][0]['message']['content']) ? trim($chatResult['choices'][0]['message']['content']) : trim($chatResult['choices'][0]['message']['reasoning_content']);
// 设置缓存
$head = empty($answer) ? "AI响应数据异常" : $answer;
}
// 自定义发布人头像
$avatar = "https://www.phprm.com/push/h5/static/avatar/danMu.png";
// 弹幕通道已经全部使用预设通道码加签, 需要额外传timestamp、nonce、sign三个参数
$signSecret = "3f506231f2bbdd44026990080d430abe";
$timestamp = getMillisecond();
$nonce = md5(time());
$rawText = "${timestamp}#${signSecret}#${nonce}";
$sign = md5($rawText);
// 假设已从AI大模型拿到了返回值, 推送后立即将$messageIdList在触发的前端注册(减少当触发filter请求)
$sendUrl = "https://www.phprm.com/services/push/trigger/${channelCode}?delayMilliseconds=1000&head=".urlencode($head) . "&avatar=".rawurlencode($avatar). "×tamp=".$timestamp. "&nonce=".$nonce. "&sign=".$sign;
$json = file_get_contents($sendUrl);
$jsonObject = json_decode($json, true);
if(empty($jsonObject) || empty($jsonObject["data"]["messageIdList"])) {
// 弹幕消息推送失败
$resultModel["code"] = -1;
$resultModel["message"] = "弹幕消息推送失败";
$resultModel["data"] = $json;
echo cross_domain(json_encode($resultModel));
exit();
}
// AI弹幕可以在共享模式给其他用户看到
foreach($jsonObject["data"]["messageIdList"] as $messageId) {
// 如果不重写settings.checkAiDanMu方法, 这里只能存$channelCode
$kv -> set($redisPrefix . "messageId:". $messageId, $channelCode, 600);
}
// AI推送的弹幕不需要后端缓存, 前端缓存了messageIdList, 只需将messageIdList返回给前端跳过过滤动作(适合所有人接收)
$messageIdList = $jsonObject["data"]["messageIdList"];
$resultModel["data"] = array("aiRestAmount" => $aiRestAmount, "messageIdList" => $messageIdList, "response" => $json);
echo cross_domain(json_encode($resultModel));
exit();
}
在你的网站任意页面引入资源 <script>,danmu.js 后, 再自定义 WebsitePusher.init(settings)初始化弹幕插件:
| 方法 | 说明 |
|---|---|
sendDanMu(item) | 主动发送弹幕(仅调试样式使用, 当前屏幕滚动, 不请求业务方后端),如需存储后再推送, 请在sendCallback回调中异步请求业务方后端, 再由后端进行推送 |
sendCallback(item, settings) | 渲染完成后(或因去重/空白被拦截后)调用sendCallback将前端弹幕回传给业务方后端 |
danMuInitCallback(settings) | 弹幕启动回调, 可以从本地LocalStorage或者后端ajax异步读取开关配置(在danMuInitCallback里面return true/false即可实现弹幕组件是否初始化) |
danMuFilter(item) | 每条弹幕分发入口(默认走过滤链) |
danMuPopCallback(item, callback) | 弹幕开始滚动前绑定点击事件,该事件触发在每条弹幕开始滚动前,会绑定action监听事件并上报给业务后端 |
checkAiDanMu(doc, pollCount) | 仅允许已注册用户触发AI弹幕功能(还可以根据业务ID进行提示词调优) |