├── server ├── requirements.txt └── server.py ├── wxapp ├── pages │ ├── index │ │ ├── index.json │ │ ├── index.wxss │ │ ├── index.wxml │ │ └── index.js │ └── logs │ │ ├── logs.json │ │ ├── logs.wxss │ │ ├── logs.wxml │ │ └── logs.js ├── sitemap.json ├── app.wxss ├── app.json ├── utils │ └── util.js ├── app.js └── project.config.json ├── .gitignore ├── assets ├── wxapp.jpg ├── server-output.png ├── wxapp-settings.jpg ├── wxapp-received-messages-1.jpg ├── wxapp-received-messages-2.jpg ├── wxapp-request-subscribe-messages.jpg └── wxapp-settings-message-subscriptions.jpg └── README.md /server/requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==0.4.0 2 | aiohttp==3.6.2 3 | -------------------------------------------------------------------------------- /wxapp/pages/index/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "usingComponents": {} 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pyc 2 | .pyo 3 | __pycache__/ 4 | 5 | .mypy_cache/ 6 | 7 | venv/ 8 | -------------------------------------------------------------------------------- /wxapp/pages/logs/logs.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "查看启动日志", 3 | "usingComponents": {} 4 | } -------------------------------------------------------------------------------- /assets/wxapp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/wxapp.jpg -------------------------------------------------------------------------------- /assets/server-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/server-output.png -------------------------------------------------------------------------------- /assets/wxapp-settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/wxapp-settings.jpg -------------------------------------------------------------------------------- /assets/wxapp-received-messages-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/wxapp-received-messages-1.jpg -------------------------------------------------------------------------------- /assets/wxapp-received-messages-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/wxapp-received-messages-2.jpg -------------------------------------------------------------------------------- /assets/wxapp-request-subscribe-messages.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/wxapp-request-subscribe-messages.jpg -------------------------------------------------------------------------------- /wxapp/pages/logs/logs.wxss: -------------------------------------------------------------------------------- 1 | .log-list { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 40rpx; 5 | } 6 | .log-item { 7 | margin: 10rpx; 8 | } 9 | -------------------------------------------------------------------------------- /assets/wxapp-settings-message-subscriptions.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhangkaizhao/wxapp-subscribe-message-demo/HEAD/assets/wxapp-settings-message-subscriptions.jpg -------------------------------------------------------------------------------- /wxapp/sitemap.json: -------------------------------------------------------------------------------- 1 | { 2 | "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", 3 | "rules": [{ 4 | "action": "allow", 5 | "page": "*" 6 | }] 7 | } -------------------------------------------------------------------------------- /wxapp/pages/logs/logs.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{index + 1}}. {{log}} 5 | 6 | 7 | -------------------------------------------------------------------------------- /wxapp/app.wxss: -------------------------------------------------------------------------------- 1 | /**app.wxss**/ 2 | .container { 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: space-between; 8 | padding: 200rpx 0; 9 | box-sizing: border-box; 10 | } 11 | -------------------------------------------------------------------------------- /wxapp/pages/logs/logs.js: -------------------------------------------------------------------------------- 1 | //logs.js 2 | const util = require('../../utils/util.js') 3 | 4 | Page({ 5 | data: { 6 | logs: [] 7 | }, 8 | onLoad: function () { 9 | this.setData({ 10 | logs: (wx.getStorageSync('logs') || []).map(log => { 11 | return util.formatTime(new Date(log)) 12 | }) 13 | }) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /wxapp/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages": [ 3 | "pages/index/index", 4 | "pages/logs/logs" 5 | ], 6 | "window": { 7 | "backgroundTextStyle": "light", 8 | "navigationBarBackgroundColor": "#fff", 9 | "navigationBarTitleText": "WeChat", 10 | "navigationBarTextStyle": "black" 11 | }, 12 | "style": "v2", 13 | "sitemapLocation": "sitemap.json" 14 | } -------------------------------------------------------------------------------- /wxapp/pages/index/index.wxss: -------------------------------------------------------------------------------- 1 | /**index.wxss**/ 2 | .userinfo { 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | .userinfo-avatar { 9 | width: 128rpx; 10 | height: 128rpx; 11 | margin: 20rpx; 12 | border-radius: 50%; 13 | } 14 | 15 | .userinfo-nickname { 16 | color: #aaa; 17 | } 18 | 19 | .usermotto { 20 | margin-top: 120px; 21 | } 22 | -------------------------------------------------------------------------------- /wxapp/utils/util.js: -------------------------------------------------------------------------------- 1 | const formatTime = date => { 2 | const year = date.getFullYear() 3 | const month = date.getMonth() + 1 4 | const day = date.getDate() 5 | const hour = date.getHours() 6 | const minute = date.getMinutes() 7 | const second = date.getSeconds() 8 | 9 | return [year, month, day].map(formatNumber).join('/') + ' ' + [hour, minute, second].map(formatNumber).join(':') 10 | } 11 | 12 | const formatNumber = n => { 13 | n = n.toString() 14 | return n[1] ? n : '0' + n 15 | } 16 | 17 | module.exports = { 18 | formatTime: formatTime 19 | } 20 | -------------------------------------------------------------------------------- /wxapp/pages/index/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{userInfo.nickName}} 8 | 9 | 10 | 11 | {{motto}} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 微信小程序订阅消息 demo 2 | 3 | 官方文档: 4 | 5 | * [小程序订阅消息](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/subscribe-message.html) 6 | 7 | 注意: 8 | 9 | * 同一个微信用户在不同的微信小程序里的 openid 是不一样的。 10 | * 某些官方接口有坑,比如服务端接口 [auth.code2Session](https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html) 在返回错误响应的时候响应头部 `Content-Type` 为 `text/plain` 而不是 JSON 的 `application/json` 或 `application/javascript` ,这导致了对 aiohttp 的 ClientResponse 实例直接使用其 json() 方法将抛出异常。 11 | * 这里测试的是一次性消息,每订阅一次可以发送一次。 12 | 13 | 截图: 14 | 15 | 小程序: 16 | 17 | ![](assets/wxapp.jpg) 18 | 19 | 请求订阅消息(一次性消息): 20 | 21 | ![](assets/wxapp-request-subscribe-messages.jpg) 22 | 23 | 小程序设置: 24 | 25 | ![](assets/wxapp-settings.jpg) 26 | 27 | 小程序设置中的订阅消息: 28 | 29 | ![](assets/wxapp-settings-message-subscriptions.jpg) 30 | 31 | 服务端的调试输出: 32 | 33 | ![](assets/server-output.png) 34 | 35 | 收到消息一: 36 | 37 | ![](assets/wxapp-received-messages-1.jpg) 38 | 39 | 收到消息二: 40 | 41 | ![](assets/wxapp-received-messages-2.jpg) 42 | -------------------------------------------------------------------------------- /wxapp/app.js: -------------------------------------------------------------------------------- 1 | //app.js 2 | App({ 3 | onLaunch: function () { 4 | // 展示本地存储能力 5 | var logs = wx.getStorageSync('logs') || [] 6 | logs.unshift(Date.now()) 7 | wx.setStorageSync('logs', logs) 8 | 9 | // 登录 10 | wx.login({ 11 | success: res => { 12 | // 发送 res.code 到后台换取 openId, sessionKey, unionId 13 | } 14 | }) 15 | // 获取用户信息 16 | wx.getSetting({ 17 | success: res => { 18 | if (res.authSetting['scope.userInfo']) { 19 | // 已经授权,可以直接调用 getUserInfo 获取头像昵称,不会弹框 20 | wx.getUserInfo({ 21 | success: res => { 22 | // 可以将 res 发送给后台解码出 unionId 23 | this.globalData.userInfo = res.userInfo 24 | 25 | // 由于 getUserInfo 是网络请求,可能会在 Page.onLoad 之后才返回 26 | // 所以此处加入 callback 以防止这种情况 27 | if (this.userInfoReadyCallback) { 28 | this.userInfoReadyCallback(res) 29 | } 30 | } 31 | }) 32 | } 33 | } 34 | }) 35 | }, 36 | globalData: { 37 | userInfo: null 38 | } 39 | }) -------------------------------------------------------------------------------- /wxapp/project.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "项目配置文件", 3 | "packOptions": { 4 | "ignore": [] 5 | }, 6 | "setting": { 7 | "urlCheck": false, 8 | "es6": true, 9 | "postcss": true, 10 | "minified": true, 11 | "newFeature": true, 12 | "coverView": true, 13 | "autoAudits": false, 14 | "showShadowRootInWxmlPanel": true, 15 | "scopeDataCheck": false, 16 | "checkInvalidKey": true, 17 | "checkSiteMap": true, 18 | "uploadWithSourceMap": true, 19 | "babelSetting": { 20 | "ignore": [], 21 | "disablePlugins": [], 22 | "outputPath": "" 23 | } 24 | }, 25 | "compileType": "miniprogram", 26 | "libVersion": "2.10.1", 27 | "appid": "your-wxapp-appid", 28 | "projectname": "you-wxapp-projectname", 29 | "debugOptions": { 30 | "hidedInDevtools": [] 31 | }, 32 | "isGameTourist": false, 33 | "simulatorType": "wechat", 34 | "simulatorPluginLibVersion": {}, 35 | "condition": { 36 | "search": { 37 | "current": -1, 38 | "list": [] 39 | }, 40 | "conversation": { 41 | "current": -1, 42 | "list": [] 43 | }, 44 | "game": { 45 | "currentL": -1, 46 | "list": [] 47 | }, 48 | "miniprogram": { 49 | "current": -1, 50 | "list": [] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /wxapp/pages/index/index.js: -------------------------------------------------------------------------------- 1 | //index.js 2 | //获取应用实例 3 | const app = getApp() 4 | 5 | Page({ 6 | data: { 7 | motto: 'Hello World', 8 | userInfo: {}, 9 | hasUserInfo: false, 10 | canIUse: wx.canIUse('button.open-type.getUserInfo') 11 | }, 12 | //事件处理函数 13 | bindViewTap: function() { 14 | wx.navigateTo({ 15 | url: '../logs/logs' 16 | }) 17 | }, 18 | onLoad: function () { 19 | if (app.globalData.userInfo) { 20 | this.setData({ 21 | userInfo: app.globalData.userInfo, 22 | hasUserInfo: true 23 | }) 24 | } else if (this.data.canIUse){ 25 | // 由于 getUserInfo 是网络请求,可能会在 Page.onLoad 之后才返回 26 | // 所以此处加入 callback 以防止这种情况 27 | app.userInfoReadyCallback = res => { 28 | this.setData({ 29 | userInfo: res.userInfo, 30 | hasUserInfo: true 31 | }) 32 | } 33 | } else { 34 | // 在没有 open-type=getUserInfo 版本的兼容处理 35 | wx.getUserInfo({ 36 | success: res => { 37 | app.globalData.userInfo = res.userInfo 38 | this.setData({ 39 | userInfo: res.userInfo, 40 | hasUserInfo: true 41 | }) 42 | } 43 | }) 44 | } 45 | }, 46 | getUserInfo: function(e) { 47 | console.log(e) 48 | app.globalData.userInfo = e.detail.userInfo 49 | this.setData({ 50 | userInfo: e.detail.userInfo, 51 | hasUserInfo: true 52 | }) 53 | }, 54 | subscribeMessages: function(e) { 55 | console.log(e) 56 | wx.requestSubscribeMessage({ 57 | tmplIds: [ 58 | 'S73bf3-i3rEnELYQyVbJBOTofmMMB-y8kQcrbgpOhjg', 59 | '2RtnwyVxdsLwhCv0a_ck6DjVFC0nFGkDlAnyGEM6FAo' 60 | ], 61 | success(res) { 62 | console.log(res) 63 | }, 64 | fail(res) { 65 | console.log(res) 66 | } 67 | }) 68 | }, 69 | syncOpenid: function(e) { 70 | wx.login({ 71 | success(res) { 72 | if (res.code) { 73 | wx.request({ 74 | url: 'http://127.0.0.1:8080/sync_openid', 75 | data: { 76 | code: res.code 77 | } 78 | }) 79 | } else { 80 | console.log('login failed', + res.errMsg) 81 | } 82 | } 83 | }) 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | 4 | import aiofiles 5 | import aiohttp 6 | from aiohttp import web 7 | 8 | APP_ID = 'your-wxapp-appid' 9 | APP_SECRET = 'your-wxapp-appsecret' 10 | 11 | MESSAGE_TEMPLATE_ID_AUDIT_RESULT = 'S73bf3-i3rEnELYQyVbJBOTofmMMB-y8kQcrbgpOhjg' 12 | MESSAGE_TEMPLATE_ID_PATROL_TASK = '2RtnwyVxdsLwhCv0a_ck6DjVFC0nFGkDlAnyGEM6FAo' 13 | 14 | ACCESS_TOKEN_URL = ( 15 | 'https://api.weixin.qq.com/cgi-bin/token' 16 | '?grant_type=client_credential' 17 | '&appid={app_id}' 18 | '&secret={app_secret}' 19 | ).format( 20 | app_id=APP_ID, 21 | app_secret=APP_SECRET) 22 | 23 | SEND_MESSAGE_URL = ( 24 | 'https://api.weixin.qq.com/cgi-bin/message/subscribe/send' 25 | '?access_token={access_token}' 26 | ) 27 | 28 | CODE2SESSION_URL = ( 29 | 'https://api.weixin.qq.com/sns/jscode2session' 30 | '?appid={app_id}' 31 | '&secret={app_secret}' 32 | '&js_code={code}' 33 | '&grant_type=authorization_code' 34 | ) 35 | 36 | async def _get_access_token(): 37 | async with aiohttp.ClientSession() as session: 38 | async with session.get(ACCESS_TOKEN_URL) as resp: 39 | retdict = await resp.json() 40 | 41 | async with aiofiles.open('token.json', 'wb') as fp: 42 | await fp.write(json.dumps(retdict).encode()) 43 | 44 | return retdict['access_token'] 45 | 46 | async def _read_access_token(): 47 | if os.path.isfile('token.json'): 48 | async with aiofiles.open('token.json', 'rb') as fp: 49 | content = await fp.read() 50 | outdict = json.loads(content.decode()) 51 | return outdict['access_token'] 52 | else: 53 | return (await _get_access_token()) 54 | 55 | async def _get_openid(code): 56 | code2session_url = CODE2SESSION_URL.format( 57 | app_id=APP_ID, 58 | app_secret=APP_SECRET, 59 | code=code) 60 | async with aiohttp.ClientSession() as session: 61 | async with session.get(code2session_url) as resp: 62 | resp_text = await resp.text() 63 | retdict = json.loads(resp_text.encode()) 64 | 65 | async with aiofiles.open('openid.json', 'wb') as fp: 66 | await fp.write(json.dumps(retdict).encode()) 67 | 68 | return retdict['openid'] 69 | 70 | async def _read_openid(): 71 | if os.path.isfile('openid.json'): 72 | async with aiofiles.open('openid.json', 'rb') as fp: 73 | content = await fp.read() 74 | outdict = json.loads(content.decode()) 75 | return outdict['openid'] 76 | return None 77 | 78 | def make_audit_result_message(openid, data): 79 | return { 80 | 'touser': openid, 81 | 'template_id': MESSAGE_TEMPLATE_ID_AUDIT_RESULT, 82 | # page -> project uri 83 | 'page': 'index', 84 | 'miniprogram_state': 'developer', 85 | 'lang': 'zh_CN', 86 | 'data': { 87 | 'phrase1': { 88 | 'value': data.get('result', '') 89 | }, 90 | 'thing2': { 91 | 'value': data.get('project_name', '') 92 | }, 93 | 'thing12': { 94 | 'value': data.get('reason', '') 95 | } 96 | } 97 | } 98 | 99 | def make_patrol_task_message(openid, data): 100 | return { 101 | 'touser': openid, 102 | 'template_id': MESSAGE_TEMPLATE_ID_PATROL_TASK, 103 | # page -> project uri 104 | 'page': 'index', 105 | 'miniprogram_state': 'developer', 106 | 'lang': 'zh_CN', 107 | 'data': { 108 | 'thing1': { 109 | 'value': data.get('patrol_type', '') 110 | }, 111 | 'thing2': { 112 | 'value': data.get('patrol_description', '') 113 | }, 114 | 'thing3': { 115 | 'value': data.get('reason', '') 116 | }, 117 | 'date4': { 118 | 'value': data.get('patrol_date', '') 119 | } 120 | } 121 | } 122 | 123 | async def _send_messages(openid): 124 | access_token = await _read_access_token() 125 | send_message_url = SEND_MESSAGE_URL.format(access_token=access_token) 126 | async with aiohttp.ClientSession() as session: 127 | audit_result_message = make_audit_result_message( 128 | openid, 129 | { 130 | 'result': '通过', 131 | 'project_name': '房屋装修', 132 | 'reason': '无', 133 | } 134 | ) 135 | print('Sending audit result message to {} ...'.format(openid)) 136 | async with session.post(send_message_url, json=audit_result_message) as resp: 137 | retdict = await resp.json() 138 | print(retdict) 139 | 140 | patrol_task_message = make_patrol_task_message( 141 | openid, 142 | { 143 | 'patrol_type': '备案审核', 144 | 'patrol_description': '项目:房屋装修', 145 | 'reason': '无', 146 | 'patrol_date': '2020-02-14', 147 | } 148 | ) 149 | print('Sending patrol task message to {} ...'.format(openid)) 150 | async with session.post(send_message_url, json=patrol_task_message) as resp: 151 | retdict = await resp.json() 152 | print(retdict) 153 | 154 | async def handle_sync_openid(request): 155 | code = request.query.get('code', '') 156 | if not code: 157 | raise web.HTTPBadRequest(text='code is required') 158 | 159 | openid = await _get_openid(code) 160 | return web.Response(text="done") 161 | 162 | async def handle_send(request): 163 | openid = await _read_openid() 164 | if openid is None: 165 | raise web.HTTPBadRequest(text='no openid synced yet') 166 | await _send_messages(openid) 167 | return web.Response(text="done") 168 | 169 | app = web.Application() 170 | app.add_routes([ 171 | web.get('/sync_openid', handle_sync_openid), 172 | web.get('/send', handle_send), 173 | ]) 174 | 175 | if __name__ == '__main__': 176 | web.run_app(app) 177 | --------------------------------------------------------------------------------