前段时间实现一个短信营销的需求。
怎么实现这个功能呢?
在处理大数据时 记住必须使用异步处理
短信营销发送大量数据如何处理
解决办法是
增加预处理操作,就是提前把队列筛选出来,跑出来,到点就直接发送,拆成3步,第一步预处理,第二步10个进程直接发,第三步将 像用户 的发送记录id更新 ,发送内容全部添加进数据库中,方便后续查看错误。(在这个过程中使用3个脚本执行)
将 预处理的数据 存入 redis 中 每次 200条数据进行存储 (存储数量根据短信接口提供商而定 )
发送脚本 执行发送 时间到达就执行推送 ,没有到达重新存入队列
数据库操作能后置就后置,这样可以大大减少 执行的时间
发送短信服务商 大汉三通短信下发(不同内容多个号码) - oss短信下发(不同内容多个号码)-该接口手册编写目的在于描述短信云CTC-OSS(以下简称CTC- OSS)具体的接口协议,供设计和开发人员在开发过程中参考,也使用户对该平台的短信上、下行流程有一个全面的了解。http://help.dahantc.com/docs/oss/1apkb3jmcqlm9.html
模版示例:
【XXXX】你好,这是白模板${1,10}示例
其中${1,10}就是一个变量 ,【xxxx】是模版签名
生成条件是由管理员自定义,将用户管理中的字段 生成 键值对应的 json 存储在数据库中 在发送时通过 sql 筛选 从而达到用户 筛选的效果
json示例: {"username":"name","gender":"famale","age":["20","50"],"zodiac":["niu","long","she","ma","unknown"],"createtime":"2021-09-29 00:00:00 - 2022-09-29 23:59:59"}
计划任务参数:是用来在用户点击短链时 区分是那一个短信营销记录
产品原链接:用于生成短链接,在生成短链接之前拼接计划任务参数
短链接:用于发送时,用户点击的链接。
为什么用短链接:
1. 缩短短信长度,降低成本
2. 视觉效果更好
3. 可统计点击数量 (前提条件是使用平台的短链接)
变量参数:则是短信模版中的变量要替换的内容 json 格式
(下拉选项中的 姓名和属相,是在发送时用于区分替换短信模版时直接替换用户表里的数据)
示例:{"1":["\u59d3\u540d",""],"2":["","\u77ed\u4fe1"]}
(参数三是短链接内容,由于是系统生成所以不给管理员添加)
【XXXX】你好${1,10},这是白模板${1,10}示例,链接 xxx.xxx.com/${1,10}
我们把其中的变量替换成 变量参数则是。
【XXXX】你好张三,这是白模板短信示例 ,链接 xxx.xxx.com/s4dcz(这就是替换后的内容)
限制人数:用于小批量用户的发送
发送记录,支付产品默认值 '-'
像短信营销这种定时任务,那么我们想到的肯定是使用脚本执行,那么数数据肯定是存在redis队列中 脚本不断读取队列,判断执行。
1、生成对应的短信内容
/**
* 生成模版内容
* @param $Template 模版内容 【XXXX】你好${1,10},这是白模板${1,10}示例,链接 xxx.xxx.com/${1,10}
* @param $variable 变量参数 {"1":["\u59d3\u540d",""],"2":["","\u77ed\u4fe1"]}
* @param $short_url 短链接 http://www.xxxx.com/b4a89
* @return array
* 返回内容 {
"str":"你好#姓名1#,这是白模板#自定义2#示例,链接 xxx.xxx.com/b4a89",
"sing":"【XXXX】",
"VariableCount":3,
"variableArray":{
"1":"姓名",
"2":"短信"
},
"short_url":"b4a89"
}
*/
function getTemplateStr($Template,$variable,$short_url){
// 获取短链接路由
$short_url = preg_replace("/(http):\/\/([^\/]+)/i",'',$short_url);
$short_url = str_replace('/','',$short_url);
// 获取短信模板 签名 如果不传入签名 使用默认签名
$sing = preg_match_all("/\[.*?\]|【.*?】/",$Template,$SingArray);
$sing = $SingArray[0][0];
// PHP正则匹配统计变量个数
preg_match_all('/\${(.*?)}/ism',$Template,$VariableArray);
// 记录变量出现的位置
$index = 0;
// 存储所有变量 出现的位置 和变量长度
$VariableInfo = [];
// 查询当前变量的位置
foreach ($VariableArray[0] as $key => $value){
$StrIndex = mb_strpos($Template,$value,$index);
// 下一次查询最开始的位置
$index = $StrIndex+strlen($value);
$VariableInfo[$key]['index'] = $StrIndex;
$VariableInfo[$key]['lenght'] = strlen($value);
$VariableInfo[$key]['max'] = substr(explode(',',$value)[1],0,-1);
}
$variable = json_decode($variable,true);
// 添加变量的个数
$count = count($VariableArray[0]);
$variableArray = [];
if (!empty($variable) && is_array($variable)){
foreach ($variable as $key => $value){
if (!empty($value[0])){
$variableArray[$key] = $value[0];
}elseif (!empty($value[1])){
$variableArray[$key] = $value[1];
}
}
}
$TemplateSubstr = '';
// 截取开始位置
$start = 0;
// 循环变量
for ($i = 1; $i <= $count; $i++){
// 临时 存储 模板内容 用于字符截取
$TemporaryTemplate = $Template;
// 记录模板总长度
if (isset($variableArray[$i])){
if($variableArray[$i] == '姓名'){
// 截取当前变量之前的字符串 截取开始位置是:上次变量出现后最后结束的位置 、 截取的字符串长度:等于当前变量开始位置 - 上个变量出现的最后结束位置
$TemplateSubstr .= mb_substr($TemporaryTemplate,$start,$VariableInfo[$i-1]['index']-$start);
$start = $VariableInfo[$i-1]['index']+$VariableInfo[$i-1]['lenght'];
// 拼接替换变量的内容
$TemplateSubstr .= "#姓名".$i."#";
// $this->output->writeln($TemplateSubstr);
}elseif ($variableArray[$i] == '属相'){
// 截取当前变量之前的字符串 截取开始位置是:上次变量出现后最后结束的位置 、 截取的字符串长度:等于当前变量开始位置 - 上个变量出现的最后结束位置
$TemplateSubstr .= mb_substr($TemporaryTemplate,$start,$VariableInfo[$i-1]['index']-$start);
$start = $VariableInfo[$i-1]['index']+$VariableInfo[$i-1]['lenght'];
// 拼接替换变量的内容
$TemplateSubstr .= "#属相".$i."#";
// $this->output->writeln($TemplateSubstr);
}else{
// 截取当前变量之前的字符串 截取开始位置是:上次变量出现后最后结束的位置 、 截取的字符串长度:等于当前变量开始位置 - 上个变量出现的最后结束位置
$TemplateSubstr .= mb_substr($TemporaryTemplate,$start,$VariableInfo[$i-1]['index']-$start);
$start = $VariableInfo[$i-1]['index']+$VariableInfo[$i-1]['lenght'];
// 拼接替换变量的内容
$TemplateSubstr .= "#自定义".$i."#";
// $this->output->writeln($TemplateSubstr);
}
}else{
// 截取当前变量之前的字符串 截取开始位置是:上次变量出现后最后结束的位置 、 截取的字符串长度:等于当前变量开始位置 - 上个变量出现的最后结束位置
$TemplateSubstr .= mb_substr($TemporaryTemplate,$start,$VariableInfo[$i-1]['index']-$start);
$start = $VariableInfo[$i-1]['index']+$VariableInfo[$i-1]['lenght'];
$TemplateSubstr .= $short_url;
// $this->output->writeln($TemplateSubstr);
}
}
$TemplateSubstr .= mb_substr($Template,$start);
$TemplateSubstr = str_replace($sing,'',$TemplateSubstr);
// $this->output->writeln($TemplateSubstr);
return ['str' => $TemplateSubstr,'sing' => $sing,'VariableCount' => $count,'variableArray' => $variableArray,'short_url' => $short_url];
}
返回内容:
2、循环总页数(每10w为一页),分页获取所有的用户信息(根据人群包管理中选择的人群包筛选条件生成对应sql)
/**
* @param $where 人群包管理生成条件 {"username":"name","gender":"famale","age":["20","50"],"zodiac":["niu","long","she","ma","unknown"],"createtime":"2021-09-29 00:00:00 - 2022-09-29 23:59:59"}
* @param string $type sql类型 默认是统计总人数 build 查询用户信息sql
* @param int $page
* @param int $limit 每次查詢 10w 条
* @return string
*/
function BuildSelect($where,$type = 'count',$page = 1,$limit = 100000){
$where = json_decode($where,true);
$gender = [
"famale" => '女',
'male' => '男',
'unknown' => 'unknown'
];
$ShengXiao = ['shu'=>'鼠', 'niu'=>'牛', 'hu'=>'虎', 'tu'=>'兔', 'long'=>'龙', 'she'=>'蛇', 'ma'=>'马', 'yang'=>'羊', 'hou'=>'猴', 'ji'=>'鸡', 'gou'=>'狗', 'zhu'=>'猪', 'unknown'=>''];
// 年龄 sql 查询条件
$AgeSql = '';
$UsernameSql = '';
$GenderSql = '';
$ZodiacSql = '';
$CreatetimeSql = '';
foreach ($where as $key => $value){
switch ($key){
case 'age':
if (!empty($value[0]) && !empty($value[1])){
$AgeSql = "( `age` >= '{$value[0]}' AND `age` <= '{$value[1]}' ) ";
}
break;
case 'username':
if (!empty($value) && $value == 'name'){
$UsernameSql = " `username` IS NOT NULL AND `username` != ' '";
}elseif (!empty($value) && $value == 'unknown'){
$UsernameSql = " ( username is NULL or username = ' ') ";
}
break;
case 'gender':
if (!empty($value)){
$GenderSql = " `gender` = '{$gender[$value]}' ";
}
break;
case 'zodiac':
foreach ($value as $index => $item){
if (!empty($item)){
if ($item == 'unknown'){
$ZodiacSql .= " zodiac is NULL or zodiac = ' ' or ";
}elseif(!empty($item)){
$ZodiacSql .= " zodiac = '{$ShengXiao[$item]}' or";
}
}
}
$ZodiacSql = substr($ZodiacSql,0,strlen($ZodiacSql)-3);
if (!empty($ZodiacSql)){
$ZodiacSql = "( {$ZodiacSql} )";
}
break;
case 'createtime':
if (!empty($value)){
$info = explode(' - ',$value);
$time1 = strtotime($info[0]);
$time2 = strtotime($info[1]);
$CreatetimeSql = " (order_create_time >= {$time1} and order_create_time <= {$time2}) ";
}
break;
}
}
if ($type == 'count'){
$sql = " SELECT count(id) as `count` FROM `sms_user` WHERE blacklist = 0";
}elseif($type == 'build'){
$sql = " SELECT `tel`,`username`,`zodiac` FROM `sms_user` WHERE blacklist = 0";
}
if(!empty($AgeSql)){
$sql .= " AND ".$AgeSql;
}
if(!empty($UsernameSql)){
$sql .= " AND ".$UsernameSql;
}
if(!empty($GenderSql)){
$sql .= " AND ".$GenderSql;
}
if(!empty($ZodiacSql)){
$sql .= " AND ".$ZodiacSql;
}
if(!empty($CreatetimeSql)){
$sql .= " AND ".$CreatetimeSql;
}
// 是否剔除发送记录
if ($this->data['send_id_status'] == 1){
$send_id_array = explode(',',$this->data['send_id']);
$send_id_where = '';
foreach ($send_id_array as $key => $value){
$send_id_where .=' AND `send_id` NOT LIKE \'%-'.$value.'-%\'';
}
$sql .= $send_id_where;
}
// 是否剔除支付产品
if ($this->data['pay_products_status'] == 1){
$pay_products_array = explode(',',$this->data['pay_products']);
$pay_products_where = '';
foreach ($pay_products_array as $key => $value){
$pay_products_where .=' AND `pay_products` NOT LIKE \'%-'.$value.'-%\'';
}
$sql .= $pay_products_where;
}
if($type == 'build'){
$CurrentPage = $page;
$CurrentPage == 1 ? $CurrentPage = 0 : $CurrentPage = $CurrentPage - 1;
$CurrentPages = $CurrentPage * $limit;
// 判断是否 限制人数
if ($this->is_astrict){
// 判断 是不是最后一页
if ($this->CountPage == $page){
// 最后人数 = 限制人数 - ((总页数 - 1) * 每次查询人数 10w)
$limit = $this->last_renshu;
}
$sql .= " GROUP BY tel ORDER BY rand() LIMIT {$CurrentPages},{$limit}";
}else{
$sql .= " GROUP BY tel LIMIT {$CurrentPages},{$limit}";
}
}
return $sql;
}
3、循环用户信息,生成短信内容
/**
* @param $Template 模板内容
* @param $UserInfo 用户数据
* @param $SendId 发送记录id
* @return array|bool
* @param $NowTime 现在时间
*/
function TemplateContent($Template,$UserInfos,$SendId,$NowTime){
// 判断是否存在模版内容
if (!empty($Template)){
// 添加变量的个数
$count = ($Template['VariableCount']);
$short_url = ($Template['short_url']);
$variableArray = $Template['variableArray'];
// 存储截取内容并重新拼接发送内容
$send_limit = 200;
// 存储发送数据
$data = [];
$sing = $Template['sing'];
$userNumber = count($UserInfos);
$this->output->writeln("用户总数".$userNumber);
$userForDegree = 0;
$isum = 0;
// 循环用户
foreach ($UserInfos as $key => $value){
// 判断用户数据手机号是否是手机号格式
if(isMobile($value['tel'])){
// 临时 存储 模板内容 用于字符截取
$TemplateSubstr = $Template['str'];
// 循环变量 替换内容
for ($i = 1; $i <= $count; $i++){
if (isset($variableArray[$i])){
if($variableArray[$i] == '姓名'){
$TemplateSubstr = str_replace("#姓名".$i."#",$value['username'],$TemplateSubstr);
}elseif ($variableArray[$i] == '属相'){
$TemplateSubstr = str_replace("#属相".$i."#",$value['zodiac'],$TemplateSubstr);
}else{
$TemplateSubstr = str_replace("#自定义".$i."#",$variableArray[$i],$TemplateSubstr);
}
}
}
$msgid = $this->GetRandStr(32);
// 生成发送数据包 每200为1个队列
$data[] = [
'msgid' => $msgid,
'phones' => $value['tel'],
'content' => $TemplateSubstr,
// 获取报表时携带的参数
'params' => ['SmsSendId' => $SendId,'ShortUrl' => $short_url,'NowTime'=>$NowTime],
// 签名
'sign' => $sing,
// 发送时间
'sendtime' => '',
];
// 当限制人数小于 10 w时
// 为什么要有 当前判断 那是因为 在小数据,多任务时,会导致 用户重复发送短信 所以在数据量 小于10万的情况下 立即给发送的用户 添加发送记录,避免用户多次发送,一般大于10万 每天则会只有一条发送任务。那么在大于10万的情况下则不需要 添加用户记录,在提交发送时 将这个步骤后置。和发送日志 一起添加到数据库中。
if ($this->count <= 100000){
// 给 用户添加发送记录
$this->SmsUserModel->query("update sms_user set send_id = CONCAT(`send_id`,'{$SendId}-') where `tel` = '{$value['tel']}'");
}
$userForDegree++;
// 判断总数是否 是 200
if (count($data) == $send_limit || $userNumber == $userForDegree){
$isum++;
$this->output->writeln('当前加入队列第'.$isum.'次');
$this->output->writeln('当前总数据包个数'.count($data));
$datas = [
'data' => $data,
'send_id' => $SendId,
'send_time' => $this->SendTime,
];
$data = [];
$PlanDate = json_encode($datas);
// 添加发送数据包到 队列中 执行发送时 读取当前队列
$this->redis->rPush('SmsSendCrowdPackage',$PlanDate);
// 由于执行发送时 使用的是多进程 当多任务时 没办法区分当前发送任务是否发送完成 设置缓存 用来判断 发送 提交次数是否和 生成数据包的数量一致 来区分是否完成全部提交
$this->redis->incr($SendId.'SmsSubmitCount');
}
}
}
unset($UserInfos);
unset($Template);
unset($data);
return true;
}else{
return false;
}
}
function GetRandStr($length){
$str='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$len=strlen($str)-1;
$randstr='';
for($i=0;$i<$length;$i++){
$num=mt_rand(0,$len);
$randstr .= $str[$num];
}
return $randstr;
}