有始有终,即使礼物她收不到了
一个待办小程序,自己可以制定一些计划,有了计划就有待办任务,任务需要每天循环来执行
任务前半个小时,系统会发送微信订阅提醒用户去执行计划
每天可以备注任务阶段性总结信息
当天的计划没有完成的,系统将在0:00自动计算并扣除分值
添加一条计划需要1分,没有分就需要分享转发来赚取
小项目就没有做权限验证,仅仅做了微信登录而已
前端采用vue2.0脚手架构建 uniapp
ui组件采用colorUI+uview2.0
数据存储采用hasura+postgresql
api接口采用graphql查询语言
一些特殊的接口采用nodejs+fastify来完成
项目采用docker+宝塔面板部署
curl https://raw.githubusercontent.com/hasura/graphql-engine/stable/install-manifests/docker-compose/docker-compose.yaml -o docker-compose.yml
version: '3.6'
services:
postgres:
image: postgres:12
ports:
- "5432:5432"
restart: always
environment:
POSTGRES_PASSWORD: postgrespassword
graphql-engine:
image: hasura/graphql-engine:v2.0.10
ports:
- "8080:8080"
depends_on:
- "postgres"
restart: always
environment:
## postgres database to store Hasura metadata
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
## enable the console served by server
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
## enable debugging mode. It is recommended to disable this in production
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
## uncomment next line to run console offline (i.e load console assets from server instead of CDN)
# HASURA_GRAPHQL_CONSOLE_ASSETS_DIR: /srv/console-assets
## uncomment next line to set an admin secret
# HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
volumes:
db_data:
进入docker-compose.yml文件所在目录,运行命令构建compose
docker compose up -d
访问控制台
http://localhost:8080/console
// const moment = require("moment"); // 不需要 const moment = require("moment-timezone"); // moment-timezone包含了moment moment.tz.setDefault("Asia/Shanghai");
fastify.post("/upload", async function (req, reply) {
fastify.pg.connect(onConnect);
// process a single file
// also, consider that if you allow to upload multiple files
// you must consume all files otherwise the promise will never fulfill
const data = await req.file();
// 获取到的fieldname是用户id+用户昵称
//把id和昵称拆分开
const gender = data.fieldname.match(/(\S*)\+/)[1].slice(0, 1);
const user_id = data.fieldname.match(/(\S*)\+/)[1].slice(1);
const user_name = data.fieldname.match(/\+(\S*)/)[1];
// 将文件名与后缀拼接 所有类型的图片都转化为.jpg 前台显示头像统一用 性别+user_id.jpg user_id的第一个字符为性别
const fileName = user_id + ".jpg";
// 图片保存路径
const pathSave = path.join(__dirname, "/upload/", fileName);
await pump(data.file, fs.createWriteStream(pathSave));
// be careful of permission issues on disk and not overwrite
// sensitive files that could cause security risks
// also, consider that if the file stream is not consumed, the promise will never fulfill
// 把用户信息更新到数据库
async function onConnect(err, client, release) {
if (err) {
console.log(err, "数据库错误");
return await err;
}
client.query(
`UPDATE public."user"
SET user_name='${user_name}', user_gender='${gender}', "avatarUrl"='${baseUrl}/getImg?id=${fileName}'
WHERE id='${user_id}';`,
async function onResult(err, result) {
// release();
const resData = await (err || result.rows);
if (resData) {
console.log(resData, "数据库resdata");
}
release();
}
);
}
reply.send({ user_name, gender, fileName, pathSave });
});
// 获取头像
fastify.get("/getImg", async (request, reply) => {
const fileName = request.query.id;
const filePath = path.join(__dirname, "/upload/", fileName);
//格式必须为 binary 否则会出错
var content = fs.readFileSync(filePath);
// response.send(content);
reply.code(200).header("Content-Type", "image/png").send(content);
});
// 微信登录获取openID
fastify.route({
method: "POST",
url: "/wxLogin",
handler: async function (request, reply) {
const options = {
method: "get",
url: "https://api.weixin.qq.com/sns/jscode2session",
qs: {
appid: appid,
secret: sss,
js_code: request.body.code,
grant_type: "authorization_code",
},
};
let sessionData = await rp(options);
if (sessionData) {
return reply.send(sessionData);
}
},
});
// 定时任务计算用户当天分数
fastify.route({
method: "POST",
url: "/action/selectUserToDoStatus",
handler: function (request, reply) {
let resData = null;
fastify.pg.connect(onConnect);
async function onConnect(err, client, release) {
if (err) return await err;
client.query(
`SELECT user_id ,count(todo_date)
FROM public.todo_item_need_do WHERE todo_date='${moment().format(
"yyyy-MM-DD"
)}' AND todo_status=false
GROUP BY user_id
;`,
async function onResult(err, result) {
resData = await (err || result.rows);
// 如果查询到有未完成的任务,就给用户减去对应分值,添加一条减分记录
if (resData.length > 0) {
//生成插入语句
let insertStr = "";
for (const item of resData) {
insertStr += `INSERT INTO public.score_item(
score_item_number, scores_source, user_id, scores_source_des)
VALUES ( -${item.count}, 6, '${item.user_id}', '每日任务完成情况统计');`;
}
await client.query(insertStr, function onResult(err, result) {
release();
reply.send(err || result);
});
}
}
);
}
},
});
async function getToken() {
//数据库连接成功 请求token-------------
// GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
const options = {
method: "get",
url: "https://api.weixin.qq.com/cgi-bin/token",
qs: {
appid: appid,
secret: sss,
grant_type: "client_credential",
},
};
let sessionData = await rp(options);
if (sessionData) {
return JSON.parse(sessionData).access_token;
} else {
return "getTokeError";
}
}
// 获取微信接口调用凭证
fastify.route({
method: "POST",
url: "/get_access_token",
handler: async function (request, reply) {
try {
const client = await fastify.pg.connect();
const accessToken = await getToken();
if (accessToken) {
const updateRes = await client.query(
`
UPDATE public."acc_tok "
SET acc_tok = '${acc_tok }',nums=nums+1
WHERE id='a5c24b324r1-43af-43ghu33308d-9815oy8f-c052hg57e1212a6d';
`
);
if (updateRes) {
return `${moment().format("yyyy-MM-DD HH:mm:ss")}:获取token成功!`;
} else {
return `${moment().format("yyyy-MM-DD HH:mm:ss")}:获取token失败!`;
}
}
} catch (r) {
request.log.error("获取微信接口调用凭证");
throw new Error(e);
}
},
});
// 定时任务发送微信小程序订阅消息
fastify.route({
method: "POST",
url: "/sendSub",
handler: async function (request, reply) {
try {
const client = await fastify.pg.connect();
const nowTime = moment().format("HH:mm");
const addTime = moment().add(30, "minutes").format("HH:mm");
const { rows } = await client.query(
`
select i.id as item_id,i.todo_item_name,
i.todo_item_description,
i.todo_remind_time,
i.todo_start_time,
i.todo_end_time,
i.todo_item_remind,
n.id as need_id,
n.todo_date as need_date,
o.openid as open_id,
t.access_token as token
from public.todo_item_need_do n,
public.todo_item i,
public.user o,
public.access_token t
where
n.todo_status = false
and n.todo_date = '${moment().format("yyyy-MM-DD")}'
and n.todo_item_id = i.id
and i.user_id = o.id
and i.todo_item_remind = true
and i.todo_remind_time between '${nowTime}' and '${addTime}'
`
);
client.release();
if (rows.length == 0) {
return `${moment().format(
"yyyy-MM-DD HH:mm:ss"
)}:数据库没有查询到相关记录!`;
}
// 循环发送订阅
const resData = await sendSubfor(rows).then((res) => {
return res;
});
console.log(resData);
let array = [];
for (const item of resData) {
let sessionData = null;
sessionData = await rp(item);
if (sessionData) {
array.push(sessionData);
}
}
if (array.length == resData.length) {
return array;
}
} catch (e) {
request.log.error("定时任务发送微信小程序订阅消息");
throw new Error(e);
}
},
});
async function sendSubfor(data) {
return new Promise(async function (resolve, reject) {
try {
let item = null;
let options = null;
let token = data[0].token;
let resData = [];
for (let index = 0; index < data.length; index++) {
// console.log(data[index],index,data.length);
item = data[index];
options = {
method: "POST",
url: `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`,
body: {
touser: item.open_id,
template_id: "bAgG6FsdI8SJTJ7BYMffqPbmdeSwAqB_ufp967yeBEhyDbUjY",
data: {
thing1: {
value: item.todo_item_name.substring(0, 19), //20个以内字符 可汉字、数字、字母或符号组合
},
thing4: {
value: item.todo_item_description.substring(0, 19),
},
time10: {
value: moment(item.todo_start_time).format("yyyy年MM月DD日"),
},
time11: {
value: moment(item.todo_end_time).format("yyyy年MM月DD日"),
},
},
},
json: true, // Automatically stringifies the body to JSON
};
resData.push(options);
}
if (resData?.length == data.length) {
resolve(resData);
}
} catch (e) {
reject(e);
}
});
}
在当前目录创建Dockerfile文件
FROM node:current-alpine3.12
# 创建并切换到应用目录。
WORKDIR /todo_action_home/
WORKDIR /todo_action_home/todo_action
# 复制本地代码到容器镜像。
COPY ./server.js /todo_action_home/todo_action
COPY ./routes.js /todo_action_home/todo_action
COPY package*.json ./
# 安装生产环境依赖。
RUN npm install
VOLUME ["/todo_action_home/todo_action"]
EXPOSE 3000
# 启动容器时运行服务。
CMD [ "node", "server.js" ]
docker build -t todo_action:1.0 .
docker save -o todo_action_1.0.tar todo_action:1.0
version: '3.6'
services:
action:
image: todo_action:1.0
ports:
- "3000:3000"
depends_on:
- "postgres"
restart: always
postgres:
image: postgres:12
ports:
- "5432:5432"
restart: always
environment:
POSTGRES_PASSWORD: postgrespassword
graphql-engine:
image: hasura/graphql-engine:v2.18.0
ports:
- "8080:8080"
depends_on:
- "postgres"
restart: always
environment:
## postgres database to store Hasura metadata
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
## enable the console served by server
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
## enable debugging mode. It is recommended to disable this in production
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
## uncomment next line to run console offline (i.e load console assets from server instead of CDN)
# HASURA_GRAPHQL_CONSOLE_ASSETS_DIR: /srv/console-assets
## uncomment next line to set an admin secret
## HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkeyweipengyu
volumes:
db_data:
找到本地docker数据存储位置
\\wsl$\docker-desktop-data\mnt\wsl\docker-desktop-data\data\docker\volumes
#PROXY-START/apiv
location /apiv
{
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_http_version 1.1;
# proxy_hide_header Upgrade;
add_header X-Cache $upstream_cache_status;
#Set Nginx Cache
set $static_filetPztmqon 0;
if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" )
{
set $static_filetPztmqon 1;
expires 1m;
}
if ( $static_filetPztmqon = 0 )
{
add_header Cache-Control no-cache;
}
}
#PROXY-END/
#PROXY-START/apiv
location /action
{
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_http_version 1.1;
# proxy_hide_header Upgrade;
add_header X-Cache $upstream_cache_status;
#Set Nginx Cache
set $static_filetPztmqon 0;
if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" )
{
set $static_filetPztmqon 1;
expires 1m;
}
if ( $static_filetPztmqon = 0 )
{
add_header Cache-Control no-cache;
}
}
#PROXY-END/
// 小程序分享功能 import share from '@/common/share.js' // 分享朋友圈 Vue.mixin(share)
// :data-item='item' 是传给onShareAppMessage的数据
<view class=" flex padding-sm justify-between align-center bg-white" >
<button class="cu-btn shareBtn sm" type="default" :data-item='item' data-name="shareBtn" open-type="share">
<text class="cuIcon-weixin text-olive">text> 转发button>
view>
// share.js
export default {
data() {
return {
shareUrl:'/uploads/20230207/4f8cd2699d32510f732267b0221bb6ad.jpg',
userInfo:null
}
},
onLoad: function() {
this.userInfo=uni.getStorageSync('userInfo');
this.shareUrl = this.$imageURL
wx.showShareMenu({
withShareTicket: true,
menus: ["shareAppMessage", "shareTimeline"]
})
},
onShareAppMessage(res) {
this.addScores()
let that = this;
console.log(res,'++++',that.$scope,"转发-->");
let imageUrl = that.shareUrl || '';
if (res.from === 'button') {
let item = res.target.dataset.item
console.log(item,"utem");
//这块需要传参,不然链接地址进去获取不到数据
let path = `/pages/todo_item_note/index` + `?todo_item_id=` + item.id;
return {
title: item.todo_item_name,
path: path,
imageUrl: item.user.avatarUrl
};
}
if (res.from === 'menu') {
return {
title: '我正在靠近自己的愿望...你来监督我吧!',
path: '/pages/index/index',
imageUrl: imageUrl
};
}
},
// 分享到朋友圈
onShareTimeline(res) {
console.log(res,"分享到朋友圈");
this.addScores()
return {
title: '我正在靠近自己的愿望...你来监督我吧!',
path: '/pages/index/index',
imageUrl: this.shareUrl
};
},
methods: {
// 分享成不成功积分都加1,腾讯把回调函数关闭了,没有办法
addScores(){
this.$addScore(this.userInfo.id).then(res=>{
console.log(res,"分享添加积分成功!");
}).catch(err=>{
console.log(err,"分享添加积分失败");
})
}
}
}
import Request from 'luch-request'
const httpGraphql = new Request()
//请求拦截器
httpGraphql.interceptors.request.use((config) => { // 可使用async await 做异步操作
//const token = uni.getStorageSync('token');
// if (token) {
config.header = {
Authorization: "xxxxxxx",
"x-hasura-admin-secret":''
}
//}
if (config.method === 'POST') {
config.data = JSON.stringify(config.data);
}
// console.log("请求拦截器");
return config
}, error => {
return Promise.resolve(error)
})
// 响应拦截器
httpGraphql.interceptors.response.use((response) => {
console.log(response,"响应拦截器");
let res = {}
if(response&&response.statusCode===200){
res.code = 200,
res.msg="ok",
res.data=response.data.data
}else{
res.code = 0,
res.msg="error",
res.data=''
}
return res
}, (error) => {
//未登录时清空缓存跳转
// if (error.statusCode == 401) {
// uni.clearStorageSync();
// uni.switchTab({
// url: "/pages/index/index.vue"
// })
// }
return Promise.resolve(error)
})
export default httpGraphql
import httpGraphql from "./graphql";
import gql from "graphql-tag";
import moment from 'moment'
import {
print
} from "graphql";
const graphqlUrl = "/apiv/v1/graphql";
// 注册及更新用户,注册时送10分
export function checkUser(variables) {
let postData = gql`
mutation insert_user_one_on_conflict($openid: String = "", $scores_number: Int = 10) {
insert_user_one(object: {openid: $openid, scores: {data: {scores_number: $scores_number}, on_conflict: {constraint: scores_user_id_key}}}, on_conflict: {constraint: user_openid_key, where: {}, update_columns: openid}) {
avatarUrl
user_name
user_gender
user_status
openid
id
}
}
`;
return httpGraphql.post(graphqlUrl, {
query: print(postData),
variables,
});
}
import Vue from 'vue'
// 用户点击添加按钮之前检查订阅消息权限,未开启提示前往开启,已开启请求订阅消息
export function checkAndRequestSubscribeMessage() {
// 为了保证用户返回到页面再添加数据,采用promise
return new Promise(function(resolve, reject) {
uni.getSetting({
withSubscriptions: true,
success(res) {
// 订阅消息总开关是否开启
if (!res?.subscriptionsSetting?.mainSwitch) {
uni.showModal({
title: '提示',
content: '当前暂未开启接消息提醒,是否前往设置页开启?',
success(res) {
if (res.confirm) {
wx.openSetting().then(res => {
resolve('ok')
}).catch(err => {
resolve('ok')
})
} else {
resolve('ok')
}
},
fail() {
resolve('ok')
}
})
} else {
uni.requestSubscribeMessage({
tmplIds: Vue.prototype.$get_tmplIds,
complete() {
resolve('ok')
}
})
}
}
})
})
}
// 添加或者修改任务时调用订阅
sbumitBefore(){
checkAndRequestSubscribeMessage().then(res=>{
// 只要res == ok 说明已经返回到该页面,继续添加任务
}).catch(err=>{
}).finally(e=>{
// 说明已经返回到该页面,继续添加任务,同不同意接受订阅消息都要给用户添加数据
this.submit()
})
},
uni.getUserProfile({
desc: "用于完善用户资料",
lang: "zh_CN",
success: (res) => {
// console.log(res)
// that.wxlogin(res.userInfo);
if (res.errMsg === "getUserProfile:ok" && res.userInfo != undefined) {
let userInfo = res.userInfo
// 调用接口请求openID
uni.login({
success(codeData) {
// 请求action,获取openID
console.log(codeData, "uni.login-codeData")
const {
openid,
} = userInfo
uni.request({
url: that.$baseUrl+'/wxLogin',
method: 'POST',
header:{
'Content-Type' : 'application/json'
},
data: {
openid,
code:codeData.code,
key:''
},
success: res => {
console.log(res,"wxLogin------------>>");
if(res.statusCode===200&&res.data.openid){
let variables = {
openid:res.data.openid,
scores_number: 10,
}
checkUser(variables).then(res => {
console.log(res, "checkUser");
if (res.code === 200 && res.data
.insert_user_one
.user_status === 1) {
// 登录成功,保存用户数据,跳转到首页
uni.setStorageSync('userInfo',
res.data
.insert_user_one)
getApp().globalData.userInfo =
res.data.insert_user_one
that.gotoPage()
} else if (res.code === 200 && res
.data.insert_user_one
.user_status === 0) {
try {
uni.clearStorageSync();
} catch (e) {
// error
}
// 账号禁用,提示用户
uni.showModal({
title: "账号禁用",
content: "请联系管理员!"
})
} else {
try {
uni.clearStorageSync();
} catch (e) {
// error
}
// 获取用户信息失败,提示重试
uni.showModal({
title: "提示",
content: "获取用户信息失败,请重试!"
})
}
}).catch(err => {
console.log(err, "checkUser");
})
}else{
uni.showModal({
title: "提示0",
content: "action未返回openID"
})
}
},
fail: err => {
console.log(err,"wxLogin------------>>");
uni.showModal({
title: "提示2",
content: "获取微信接口失败,请重试!"
})
},
complete: () => {}
});
},
});
} else {
uni.showModal({
content: "uni.getUserProfile获取用户信息失败!",
title: "提示"
})
}
},
fail(err) {
console.log(err, "err");
uni.showModal({
content: '网络状况不佳或者基础库高于2.7.0',
title: "uni.getUserProfile-fail获取用户信息失败!"
})
},
});
送给彬婧
2023年2月17日04:25:08
todo
action