├── 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 | 
18 |
19 | 请求订阅消息(一次性消息):
20 |
21 | 
22 |
23 | 小程序设置:
24 |
25 | 
26 |
27 | 小程序设置中的订阅消息:
28 |
29 | 
30 |
31 | 服务端的调试输出:
32 |
33 | 
34 |
35 | 收到消息一:
36 |
37 | 
38 |
39 | 收到消息二:
40 |
41 | 
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 |
--------------------------------------------------------------------------------