长期订阅消息通知

Tips:必须创建一个服务号,服务号不允许个人注册 公众号分为订阅号与服务号,只有服务号可以对用户主动、定向的长期通知。 该解决方案本质为通过服务号向关注用户发通知,从而绕开小程序每次发通知都需要授权一次的限制。开发者必须拿到服务号的长期运营者权限,以及app secret,而小程序需要提醒用户关注绑定的服务号(可以使用zion的“微信专用-微信关注公众号”组件) 效果如下:

注册服务号

  • 在微信开放平台注册账号,实现小程序和服务号的关联
  • 在账号中心中验证开发者资质,用同一个企业来认证
  • 进入“管理中心”->"小程序",绑定之前开通的小程序,过程中需要管理员扫描二维码验证
  • 进入“管理中心”->"公众账号",绑定之前开通的服务号tip:申请网址:[https://open.weixin.qq.com]

关联服务号

进入公众号管理后台,关联小程序+进入小程序管理后台,关联服务号

Tips:公众号和小程序管理后台: https://mp.weixin.qq.com

在双后台都绑定成功后, unionId会存在account的oauth2_user_info_map/客户验证里

在实现绑定后,所有用户(无论新老)下一次登录小程序时,会立即获取到其unionid。 概念说明: 小程序用户第一次登录小程序时,小程序会根据其微信号为其生成独一无二的 openId,即使我们将 account 中的数据删除,用户再次登录时生成的 account 的 openId 也是原值。(因此一种比较骚鸡的测试方法是,如果某个用户表示自己前台有错误,但是我们自己账号登录后看不出是啥问题,我们可以直接把我们和对方 account 中的 oauth2_user_info_map 进行调换,来模拟另一个账号的小程序体验,测试完了换回来。建议在 altair上 写作 gql 脚本,方便一些)

而公众号也是同理,在公众号后端也会存储关注用户的 openId。问题在于,不同公众号、不同小程序为用户生成的 openId 的值并不相同,而绑定公众号与小程序后,微信会自动在绑定的公众号与小程序后端添加一个 unionId 字段。相同的微信号的 unionId 也相同,从而让开发者可以通过小程序的 openId 拿到 unionId

进入公众号管理后台,申请模板消息

路径:新的功能——模板消息 tip:申请时如需设置行业,可选择“IT科技” -> “IT软件与服务”

配置相关 API

zed 和 postman 上的调试模式分别生成伪ip,真正投入使用时,使用到的ip是以下三个: 172.28.0.1、172.17.0.1 、172.29.0.29,都需添加进去。 access_token每天只能获取200次,有效期是7200秒。所以应该把它加入缓存,而不是每次都去获取新的access_token

Tips:

1. 在公网服务器部署接口机
2. 接口机的作用是利用服务号的开发接口,通过union ID来获取服务号用户的open ID 
3. 原理说明如下:如果用户关注了服务号,那么就可以通过服务号开发接口获取用户的union ID和open ID
4. 小程序用户登录后,可以获取小程序的union ID和open ID,在小程序端,通过union ID向接口机发送请求,看是否能找到同一个unionid下的服务号openid,如能找到,则缓存到数据库,用于以后的模板消息下发
5. 部署完接口机后,要把接口机公网地址加到小程序的白名单中
6. 同时把接口机的IP地址加到公众号平台->开发->基本配置的IP白名单中

发送模板消息 POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN body(color不是必填项,keyword有几个根据模板决定): { "touser":待发送用户的公众号openid, "template_id":模板id, "miniprogram":{ "appid":小程序appid, "pagepath":"pages23/kzz6pc5e/kzz6pc5e?linkdata=xxx" }, "data":{ "first": { "value":"恭喜你购买成功!", "color":"#173177" }, "keyword1":{ "value":"巧克力", "color":"#173177" }, "keyword2": { "value":"39.8元", "color":"#173177" }, "remark":{ "value":"欢迎再次购买!", "color":"#173177" } } }

  • 分支1:长期定时通知对于一些触发需通知的前端事件过于频繁的小程序,比如带聊天或社区回复功能的程序,如果每一次对方发来消息都需要服务号发送通知,那么用户体验会极端糟糕,因此长期订阅通知方案的一个重要分支是定时性长期通知。 定时通知实现方式也比较多样,这里介绍其中一种: -通过将api配置在zion前端,从而可以通过自定义行为中的context.runGql直接调用第三方api 可以采用altair进行调试,在doc中搜索operation关键词即可搜到:

将该自定义行为设置为定时任务,从而每日自动触发

curl 'https://zionbackend.functorz.com/api/graphql' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'Origin: altair://-' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ6aW9uYmFja 2VuZCIsInJmIjoiTm55S3ZrbSIsInJvbGVzIjpbImFkbWluIl0sInN1YiI6Iks1ekpycUVxa2QwIiwiaWF0IjoxNjMwNDgyMjc3LCJleHAiOjE2MzA1Njg2Nzd9.SqSMDv8QsVjIgNI hg9UklcjTprvWJr-NHtlAEWnfWN4' --data-binary '{"query":"\n\nmutation jj {\n addScheduledJob(\n schemaExId: \"Ywng9oV59KX\"\n input: { \n shouldRecover: true\n description: \"定时获取订单在ERP中的信息\"\n triggerConfigs: [\n {\n triggerType: \"cro n\"\n name: \"updateOrderShippingStatus\"\n group: \"updateOrderShippingStatus\"\n cron: \"0 0 * ? * * *\"\n }\n ]\n name: \"updateOrderShippingStatus\"\n group: \"updateOrderShippingStatus\"\n durable: true\n dataMap: {\n actionFlowUuid: \"03664ed6-1a92-4cb1-bb36-cb7c022c22f5\"\n inputs: {}\n versionId: 3\n }\n jobType: ACTION_FLOW\n }\n ) {\n name\n }\n}\n\n# mutation shareProjectTemplate {\n# shareProjectTemplate(\n# input: {\n# name: \"活动模版-测试\"\n# platforms: [WECHAT_MINI_PROGRAM]\n# schemaExId: \"8xVPo7Qb7Xv\"\n# description: \" 描述待添加\"\n# coverImageId: 7000000000209925\n# previewImageIds: [\n# 7000000000209324\n# 7000000000209323\n# 7000000000209322\n# 7000000000209325\n# 7000000000209326\n# ]\n# category:E_COMMERCE\n# }\n# templateExId: \"XAPxOjv29DV\"\n# )\n# }\n\nmutation syncCallbackConfigs($callbackConfigs: Json!) {\n syncCallbackConfigs(\n callbackConfigs: $callbackConfigs,\n deploymentEnvConfigExId: \"ZbkqNwzOmD7\"\n )\n}\n\nmutation deployZionAppBackend {\n deployZionAppBackend(deploymentnvConfigExId: \"ZbkqNwzOmD7\")\n}\n\n\n\n","variables":{"callbackConfigs":[{"uniqueId":"348aaf25-6f2a-48c6-b1df-937fdbda151b","parameters":[],"actions":[{"actionFlowUniqueId":"3698fbb8-3782-45d0-886b-3585ac3dfe13","paymentType":"WECHATPAY_MINIPROGRAM","version":59}]}]}}' --compressed
  • url: https://zionbackend.functorz.com/api/graphql
  • token:编辑项目的时候,发送的token
  • 对应接口

    mutation jj { addScheduledJob( schemaExId: "Ywng9oV59KX" input: { shouldRecover: true description: "定时获取订单在ERP中的信息" triggerConfigs: [ { triggerType: "cron" name: "updateOrderShippingStatus" group: "updateOrderShippingStatus" cron: "0 */1 * * *" } ] name: "updateOrderShippingStatus" group: "updateOrderShippingStatus" durable: true dataMap: { actionFlowUuid: "03664ed6-1a92-4cb1-bb36-cb7c022c22f5" inputs: {} versionId: 3 } jobType: ACTION_FLOW } ) { name } }
    
  • schemaExId: 编辑项目的时候,项目json的ExId

  • cron:用于定义执行周期,主要是7位,https://www.freeformatter.com/cron-expression-generator-quartz.html
  • actionFlowUuid:对应actionflow的uuid
  • versionId:对应actionflow的versionId执行请求后,需要重新部署一次项目才会生效。 在具体操作中,主要分为以下3个板块,为了可读性这里只展示相关gql(实际上gql写出来后,自定义行为加一些变量拼接和循环体即可): i. 获取token 由于是定时任务,触发频率不会很高,可以基本忽略每天200次的限制。这一步不再赘述,获取到token存到变量里。 ii. 同步信息 作用是将公众号的openId同步到项目数据库中,便于后续通知,其中包含一个循环体。
#同步步骤1:获取公众号的所有用户的openidlist 
query getOaUserList($token: String) { operation_l3shtmv2(access_token: $token) { field_200_json { total count data { openid } next_openid errcode errmsg } field_400_json field_500_json responseCode } } 
#将这一步获取到的openidlist作为循环数据源,for each openid in openidlist,$openid用在下一步 
#同步步骤2(循环中):进入循环,通过openid获取对应的unionid。 
query getUnionId($token:String,$openid:String){ operation_l3sjreau( access_token: $token openid: $openid ) { field_200_json { openid unionid } field_400_json field_500_json responseCode } } 
#同步步骤3(循环中):继续循环,根据获取到的unionid找出小程序数据库中的对应账户,将对应的openid更新到oaopenid字段中。 #其中为了适配zion对json字段的_contains查询办法,需要基于unionid构造一个unionObject。(另外关注了公众号,但是没有登录过小程序的用户由于没有account条目,是无法更新的) 
mutation account($unionid:String,$unionobject:jsonb,$openid:String){ update_account(where: {oauth2_user_info_map:{_contains:$unionobject}}, _set:{unionid:$unionid,oaopenid:$openid}){ affected_rows returning{ id unionid oaopenid } } } #同步循环结束。 variables: { "token":"57_57UJbh7x4mwpeNNv0UM63G022dgCd16J10wQCClY90yOgS2iQshit", "unionId": "o50HH6LhofPi_cInPhJVzSKq", "unionobject": { "WECHAT":{ "unionId": "o50HH6LhofPi_cInPhJVzSKq" } }, "openid":"oL8KI5vftjG1RFq9lnBkF05PAf" }
  • 发送通知通知步骤:在同步结束后,开始进入通知环节: -1获取哪些人需要通知(剔除没关注公众号的人和没有消息需通知的人); -2获取需要分别通知什么信息; -3进入循环体,对各用户进行通知这里建议如果1、2两步可以写在一个query里,就尽可能写在一个query里,否则还需要拼接对象数组才能进入第三步循环,会增加很多难度。 定时通知一般都会利用到聚合方法,比如“您有n条消息未读”此类,而这些未读消息所在的模型一般都是account表的(间接)下级表,比如下面的案例中,专门有一张point_count表作为未读消息表:

在这种情况下,query就会涉及到“根据包含的下级表条目进行过滤”以及aggregate方法:

#通知步骤1:获取关注了公众号也登陆了小程序、且有消息需要通知的各个正式用户的未读消息,并且按照消息类型分类 
query notice{ account(where:{_and:[ {oaopenid:{_is_null:false}}, {point_count:{id:{_is_null:false}}} {_or:[ {user_status:{_eq:"经纪人"}}, {user_status:{_eq:"新人"}}]} ]}){ oaopenid, chatnotice:point_count_aggregate(where:{type:{_eq:"聊天"}}){ aggregate{ count } }, nonchatnotice:point_count_aggregate(where:{type:{_eq:"投递邀约"}}){ aggregate{ count } } } }

其中oaopenid是同步信息环节中同步过来的公众号openid信息,如果没有,则说明用户未关注公众号; {point_count:{id:{_is_null:false}}}是在过滤出“有未读消息的用户”; Aggregate filed支持进一步过滤,就像zed上可以点开聚合数据进一步过滤一样。 下面则将上一步获得的结果作为循环数据源,进行循环通知:

#将返回结果作为循环数据源,将chatnotice、nonchatnotice组合进notice变量中("聊天相关消息chatnotice条,投递邀约相关消息nonchatnotice条"),将oaopenid放入变量中。templateid是模板id。下面进入循环体 #通知步骤2(循环中): 
mutation noticeapi($token:String!,$oaopenid:String!,$templateid:String!,$notice:String!,$name:String!){ operation_l3sjvc39( access_token: $token fz_body: { touser: $oaopenid, template_id: $templateid, miniprogram:{ appid:"wx69ac06ff50dfe2b6", pagepath:"pages23/kzz6pc5e/kzz6pc5e" }, data: { first:{value:"账号今日未读消息"}, keyword1:{value:$name} keyword2:{value:$notice}, remark:{value:"更多信息请登录小程序查看"} } } ) { field_200_json { errcode errmsg msgid } field_400_json field_500_json responseCode } } #循环结束。

效果如下:

  • 分支2:长期实时通知实时通知相比于定时通知,需要在前端配置api通知行为,此外还需要解决以下3个问题: i. token的缓存问题 access_token每天只能获取200次,有效期是7200秒。所以应该把它加入缓存,而不是每次都去获取新的access_token。 这个问题的解决方案为: 添加一个数据模型,字段:token、expire_time,专门用来存储token信息。每次需要调用token前检查库中token的expire_time是否小于当前时间,如是,重新获取并覆盖库中原条目,将expire_time设置为(当前时间+6000秒),反之沿用原token。 tip:6000秒是为了保险起见,和7200秒保持一定距离即可 示例代码,功能为缓存token未过期就拿缓存token,过期了就调用api获取最新token并且更新进缓存表。(getsec为额外用来拿secret的一个api,可以忽略),其中actok是用来缓存token的表,tok字段存token,expiredtime存过期时间:
const code = context.getArg('code'); 
function getcache() { const gql = `query getcache{ actok{ tok expiredtime } } `; 
return context.runGql('getcache', gql, {}, { role: 'admin' }).actok[0]; } 
const cache = getcache(); if (new Date(cache.expiredtime) > new Date()) { var tok = cache.tok; } else { function getsec(variables) { const gql = `query getcode($code:String) { operation_l3sgu7r2( code: $code ) { field_200_json { secert } field_400_json field_500_json responseCode } }`; return context.runGql('getcode', gql, variables, { role: 'admin' }).operation_l3sgu7r2.field_200_json.secert; } const sec = getsec({ code: code }); console.log(sec); function gettoken(variables) { const gql = `query getAccessToken($secret: String) { operation_l3sgvnmy( grant_type: "client_credential" appid: "wxcc99eeed41fadcbd" secret: $secret ) { field_200_json { access_token expires_in } field_400_json field_500_json responseCode } }`; return context.runGql('getAccessToken', gql, variables, { role: 'admin' }).operation_l3sgvnmy.field_200_json; } function timeFormat(seconds) { const timeOffset = (480 + new Date().getTimezoneOffset()) * 60 * 1000; const date = new Date(Date.now() + timeOffset + seconds * 1000); const time = date.getTime(); return time; } const tokjson = gettoken({ secret: sec }); const expiredtime = timeFormat(tokjson.expires_in-600); console.log(tokjson.access_token, expiredtime); const cachevar = { object: { tok: tokjson.access_token, expiredtime: expiredtime } }; function updatecache(variables) { const gql = `mutation tokenupdate($object: actok_set_input){ update_actok(where:{},_set:$object){ returning{ tok } } } ` return context.runGql('tokenupdate', gql, variables, { role: 'admin' }).update_actok.returning[0].tok; } var tok = updatecache(cachevar); } context.setReturn('tok', tok);

ii. 同步循环问题 不可能每次前端触发通知时就用循环同步一次账号库,否则随着公众号关注人数达到百人千人以上时速度会很慢,因此需要引入定时通知中的定时任务来同步账号信息,同时也要考虑到缓存token的情况,这里试举一例:

const code = 'xxxx'; 
//获取缓存token 
function getcache() { const gql = `query getcache{ actok{ tok expiredtime } } `; return context.runGql('getcache', gql, {}, { role: 'admin' }).actok[0]; } const cache = getcache(); if (new Date(cache.expiredtime) > new Date()) { var tok = cache.tok; } else { function getsec(variables) { const gql = `query getcode($code:String) { operation_l3sgu7r2( code: $code ) { field_200_json { secert } field_400_json field_500_json responseCode } }`; return context.runGql('getcode', gql, variables, { role: 'admin' }).operation_l3sgu7r2.field_200_json.secert; } const sec = getsec({ code: code }); console.log(sec); function gettoken(variables) { const gql = `query getAccessToken($secret: String) { operation_l3sgvnmy( grant_type: "client_credential" appid: "wxcc99eeed41fadcbd" secret: $secret ) { field_200_json { access_token expires_in } field_400_json field_500_json responseCode } }`; return context.runGql('getAccessToken', gql, variables, { role: 'admin' }).operation_l3sgvnmy.field_200_json; } function timeFormat(seconds) { const timeOffset = (480 + new Date().getTimezoneOffset()) * 60 * 1000; const date = new Date(Date.now() + timeOffset + seconds * 1000); const time = date.getTime(); return time; } const tokjson = gettoken({ secret: sec }); const expiredtime = timeFormat(tokjson.expires_in-600); console.log(tokjson.access_token, expiredtime); const cachevar = { object: { tok: tokjson.access_token, expiredtime: expiredtime } }; function updatecache(variables) { const gql = `mutation tokenupdate($object: actok_set_input){ update_actok(where:{},_set:$object){ returning{ tok } } } ` return context.runGql('tokenupdate', gql, variables, { role: 'admin' }).update_actok.returning[0].tok; } var tok = updatecache(cachevar); } 
// 同步步骤:作用是将公众号的openId同步到项目数据库中,便于后续通知。 // 同步步骤1:获取公众号的所有用户的openidlist 
function getOaUserList(variables){ const gql = `query getOaUserList($token: String) { operation_l3shtmv2(access_token: $token) { field_200_json { total count data { openid } next_openid errcode errmsg } field_400_json field_500_json responseCode } } `; return context.runGql('getOaUserList', gql, variables, { role: 'admin' }).operation_l3shtmv2.field_200_json.data.openid; } const openidlist = getOaUserList({token:tok}); console.log(openidlist); 
// #将这一步获取到的openidlist作为循环数据源,后面的变量for each openid in openidlist。 // #同步步骤2(循环中):进入循环,通过openid获取对应的unionid。 
function getUnionId(variables){ const gql =`query getUnionId($token:String,$openid:String){ operation_l3sjreau( access_token: $token openid: $openid ) { field_200_json { openid unionid } field_400_json field_500_json responseCode } } `; return context.runGql('getUnionId',gql,variables,{role:'admin'}).operation_l3sjreau.field_200_json.unionid; } /
/ #同步步骤3(循环中):继续循环,根据获取到的unionid找出小程序数据库中的对应账户,将对应的openid更新到oaopenid字段中。 
function accountupdate(variables){ const gql = `mutation accountupdate($unionid:String,$unionobject:jsonb,$openid:String){ update_account(where:{oauth2_user_info_map:{_contains:$unionobject}},_set:{unionid:$unionid,oaopenid:$openid}){ affected_rows returning{ id unionid oaopenid } } } `; return context.runGql('accountupdate',gql,variables,{role:'admin'}).update_account.returning[0]; } 
// #其中为了适配zion对json字段的查询办法,需要基于unionid构造一个unionObject。(另外关注了公众号,但是没有登录过小程序的用户由于没有account条目,是无法更新的) 
for (var i in openidlist) { console.log(openidlist[i]); var unionid = getUnionId({token:tok,openid:openidlist[i]}); var unionobject = { "WECHAT":{ "unionId": unionid } }; accountupdate({unionid:unionid,unionobject:unionobject,openid:openidlist[i]}); }

iii. 通知延时问题 即便是实时通知,也不会真的每收到一条消息就通知一次,而会有一定的延迟,比如10分钟内通知过一次,就不会再通知。否则用户体验会十分糟糕。 实现这一点的方案为: 专门做一个notice表,作为account的下级表,字段为:account_account(关系字段),expire_time,其中account_account设置唯一约束; 每次通知后,都添加一个条目,account_account为已登录用户id,expire_time=当前时间+600秒,冲突规则选account唯一约束,冲突时更新覆盖。600秒根据决定的延迟时间定。 每次通知前,判断account_account=已登录用户的notice条目的expire_time是否小于当前时间,是则通知,反之不通知。 比起定时通知,实时通知内容一般要随着信息的条目有所变化,比如只收到了一条信息则显示信息的具体内容“你收到了来自xx的信息:你好”,多条则显示“你共收到了2次消息”,会更加复杂。

Copyright © FunctorZ 2024 all right reserved修订时间: 2024-10-12 10:57:53

results matching ""

    No results matching ""