├── README.md └── worker.js /README.md: -------------------------------------------------------------------------------- 1 | # car-qrcode-notify 2 | 挪车二维码 3 | 4 | 在大佬的基础上增加了,部分后台管理功能(需要绑定 KV 数据库),可通过 api 添加,删除,更新、查看车辆列表,部署一次可多辆车辆一起使用,无需重复部署。通知发送、获取手机号都由服务器处理,通知渠道支持 WxPusher、Bark、飞书机器人、微信机器人、钉钉机器人、NapCatQQ、Lagrange.Onebot,如需更多渠道可自行增加。可设置发送通知的速率,可设置首页风格。 5 | 6 | 【V1.5更新内容】\ 7 | 1、增加消息通知、电话通知开关,可单独控制通知功能开启(默认为全开) \ 8 | 2、修复已知问题 9 | 10 | 【V1.4更新内容】\ 11 | 1、后台管理增加登录页面https://xxxxxx.workers.dev/login \ 12 | 2、移除/add页面,添加车辆登录后,统一到/manager页面添加 \ 13 | 3、增加车牌号字段,发送通知时会附带车牌号。 14 | 15 | 【V1.3 更新内容】\ 16 | 1、新增一套首页风格,https://xxxxxx.workers.dev/?id=1&style=2 ,默认为风格1(sytle=1 风格1,style=2 风格2)\ 17 | 2、页面交互优化\ 18 | 3、修复已知问题 19 | 20 | 【V1.2 更新内容】\ 21 | 1、增加多个通知渠道现在支持(WxPusher、Bark、飞书机器人、微信机器人、钉钉机器人、NapCatQQ、Lagrange.Onebot)\ 22 | 2、修复已知问题 23 | 24 | # 正文开始 25 | 26 | 1、复制 [worker.js](https://github.com/oozzbb/car-qrcode-notify/blob/main/worker.js) 文件内的代码部署到 cloudflare workers 即可\ 27 | 2、在新建的 workers 的设置中绑定 KV 数据库,具体如下图(变量名称必须为 DATA) 28 | ![image](https://github.com/user-attachments/assets/b1641ff6-92d4-44bb-8edf-d598f2f188b3) 29 | 30 | # API 部分 31 | 32 | 可通过 http 请求软件,或者去 KV 数据库直接修改,所有请求都为 POST 模式,不可在浏览器中直接访问,请求时需要添加 Authorization: Bearer API_KEY 授权头\ 33 | 34 | ``` 35 | 添加车辆api 36 | * https://xxxxxx.workers.dev/api/addOwner 37 | {"id":"1","phone":"1234567890","notifyType":"1","notifyToken":"AT_xxxxxx|UID_xxxxxx"} 38 | id:每台车辆唯一标识 39 | phone:手机号 40 | notifyType:通知方式,notifyTypeMap对应即可 41 | notifyToken:通知渠道所使用的token 42 | 43 | notifyType为1则使用wxpusher,notifyToken格式为AT_xxxxxx|UID_xxxxxx 44 | notifyType为2则使用bark,notifyToken为bark token 45 | notifyType为3则使用feishu,notifyToken为xxxxxx的值,不需要输入完整链接,https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxx 46 | 47 | 删除车辆api 48 | * https://xxxxxx.workers.dev/api/deleteOwner 49 | {"id":"1"} 50 | 51 | 车辆列表api 52 | * https://xxxxxx.workers.dev/api/listOwner 53 | ``` 54 | 55 | # 使用方法 56 | 57 | 部署完后访问你自己的 workers 即可\ 58 | 1、https://xxxxxx.workers.dev/add 访问你自己的链接添加车辆 (该页面已移除,请访问登录页面,进后台添加) \ 59 | 2、https://xxxxxx.workers.dev/login 后台车辆管理登录页面\ 60 | 3、https://xxxxxx.workers.dev/manager 后台车辆管理页面\ 61 | 4、https://xxxxxx.workers.dev/?id=1&style=2 默认为风格1(sytle=1 风格1,style=2 风格2)访问你自己的链接发送通知或拨打电话。需要带相应的车辆 id 62 | 63 | ![1731510826119](https://github.com/user-attachments/assets/eb400783-25f4-49f2-bda7-afba87e0adbd) 64 | 65 | ![1731591538885](https://github.com/user-attachments/assets/be461088-4769-45ea-b11d-bfe1f7e104c9) 66 | 67 | ![image](https://github.com/user-attachments/assets/c7070a26-83c0-4c29-993f-cc8107488151) 68 | 69 | ![1731510915932](https://github.com/user-attachments/assets/22def089-bfc9-407e-b083-8c5898fd3b31) 70 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | //Version:1.5.0 2 | //Date:2024-11-22 10:50:47 3 | 4 | addEventListener('fetch', event => { 5 | event.respondWith(handleRequest(event.request)); 6 | }); 7 | 8 | //防止被滥用,在添加车辆信息时需要用来鉴权 9 | const API_KEY = "sk-@Admin123"; 10 | const notifyMessage = "您好,有人需要您挪车,请及时处理。"; 11 | const sendSuccessMessage = "您好,我已收到你的挪车通知,我正在赶来的路上,请稍等片刻!"; 12 | //300秒内可发送5次通知 13 | const rateLimitDelay = 300; 14 | const rateLimitMaxRequests = 5; 15 | //达到速率限制时返回内容 16 | const rateLimitMessage = "我正在赶来的路上,请稍等片刻~~~"; 17 | 18 | //通知类型,其他的通知类型可自行实现 19 | const notifyTypeMap = [ 20 | { "id": "1", "name": "WxPusher", "functionName": wxpusher, "tip": "\r\nAT_xxxxxx|UID_xxxxxx" }, 21 | { "id": "2", "name": "Bark", "functionName": bark, "tip": "\r\ntoken|soundName\r\n\r\n注:token为xxxxxx代表的值,直接输入该值即可,请勿输入完整链接(https://api.day.app/xxxxxx),soundName为铃声名称(默认使用:multiwayinvitation),如需自定义铃声需要把铃声文件先上传到BarkApp" }, 22 | { "id": "3", "name": "飞书机器人", "functionName": feishu, "tip": "\r\ntoken\r\n\r\n注:token为xxxxxx代表的值,直接输入该值即可,请勿输入完整链接(https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxx)" }, 23 | { "id": "4", "name": "企业微信机器人", "functionName": weixin, "tip": "\r\ntoken\r\n\r\n注:token为xxxxxx代表的值,直接输入该值即可,请勿输入完整链接(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxx)" }, 24 | { "id": "5", "name": "钉钉机器人", "functionName": dingtalk, "tip": "\r\ntoken\r\n\r\n注:token为xxxxxx代表的值,直接输入该值即可,请勿输入完整链接(https://oapi.dingtalk.com/robot/send?access_token=xxxxxx)" }, 25 | { "id": "6", "name": "NapCatQQ", "functionName": onebot, "tip": "http://127.0.0.1:8000/send_private_msg|access_token|接收人QQ号" }, 26 | { "id": "7", "name": "Lagrange.Onebot", "functionName": onebot, "tip": "http://127.0.0.1:8000/send_private_msg|access_token|接收人QQ号" } 27 | ] 28 | 29 | async function handleRequest(request) { 30 | try { 31 | const url = new URL(request.url); 32 | const pathname = url.pathname; 33 | if (request.method === "OPTIONS") { 34 | return getResponse("", 204); 35 | } 36 | else if (request.method == "POST") { 37 | if (pathname == '/api/notifyOwner') { 38 | const json = await request.json(); 39 | return await notifyOwner(json); 40 | } 41 | else if (pathname == '/api/callOwner') { 42 | const json = await request.json(); 43 | return await callOwner(json); 44 | } 45 | else if (pathname == '/api/addOwner') { 46 | if (!isAuth(request)) { 47 | return getResponse(JSON.stringify({ code: 500, data: "Auth error", message: "fail" }), 200); 48 | } 49 | const json = await request.json(); 50 | return await addOwner(json); 51 | } 52 | else if (pathname == '/api/deleteOwner') { 53 | if (!isAuth(request)) { 54 | return getResponse(JSON.stringify({ code: 500, data: "Auth error", message: "fail" }), 200); 55 | } 56 | const json = await request.json(); 57 | return await deleteOwner(json); 58 | } 59 | else if (pathname == '/api/listOwner') { 60 | if (!isAuth(request)) { 61 | return getResponse(JSON.stringify({ code: 500, data: "Auth error", message: "fail" }), 200); 62 | } 63 | return await listOwner(); 64 | } 65 | else if (pathname == '/api/notifyTypeList') { 66 | return getNotifyTypeList(); 67 | } 68 | else if (pathname == '/api/login') { 69 | const { apiKey } = await request.json(); 70 | if (apiKey && apiKey == API_KEY) { 71 | return getResponse(JSON.stringify({ code: 200, data: "Authorized", message: "success" }), 200); 72 | } 73 | else { 74 | return getResponse(JSON.stringify({ code: 401, data: "Unauthorized", message: "fail" }), 200); 75 | } 76 | } 77 | } 78 | else if (request.method == "GET") { 79 | if (pathname == "/login") { 80 | return login(); 81 | } 82 | else if (pathname == "/manager") { 83 | return managerOwnerIndex(); 84 | } 85 | else { 86 | const style = url.searchParams.get("style") || "1"; 87 | const id = url.searchParams.get("id") || ""; 88 | return style == "2" ? await index2(id) : await index1(id); 89 | } 90 | } 91 | } catch (error) { 92 | return getResponse(JSON.stringify({ code: 500, data: error.message, message: "fail" }), 200); 93 | } 94 | } 95 | 96 | function isAuth(request) { 97 | const authHeader = request.headers.get("Authorization"); 98 | if (!authHeader || !authHeader.startsWith("Bearer ") || authHeader.split(" ")[1] !== API_KEY) { 99 | return false; 100 | } 101 | else { 102 | return true; 103 | } 104 | } 105 | 106 | async function getKV(id) { 107 | try { 108 | if (id) { 109 | const owner = await DATA.get(id) || null; 110 | if (owner) { 111 | return JSON.parse(owner); 112 | } 113 | } 114 | } catch (e) { 115 | } 116 | return null; 117 | } 118 | 119 | async function putKV(id, owner, cfg) { 120 | if (id) { 121 | await DATA.put(id, JSON.stringify(owner), cfg); 122 | return true; 123 | } 124 | else { 125 | return false; 126 | } 127 | } 128 | 129 | async function delKV(id) { 130 | if (id) { 131 | await DATA.delete(id); 132 | return true; 133 | } 134 | else { 135 | return false; 136 | } 137 | } 138 | 139 | async function listKV(prefix, limit) { 140 | return await DATA.list({ prefix, limit }); 141 | } 142 | 143 | async function rateLimit(id) { 144 | const key = `ratelimit:${id.toLowerCase()}`; 145 | const currentCount = await getKV(key) || 0; 146 | const notifyCount = parseInt(currentCount); 147 | if (notifyCount >= rateLimitMaxRequests) { 148 | return false; 149 | } 150 | await putKV(key, notifyCount + 1, { 151 | expirationTtl: rateLimitDelay 152 | }); 153 | return true 154 | } 155 | 156 | async function notifyOwner(json) { 157 | const { id, message } = json; 158 | const isCanSend = await rateLimit(id); 159 | if (!isCanSend) { 160 | return getResponse(JSON.stringify({ code: 200, data: rateLimitMessage, message: "success" }), 200); 161 | } 162 | const owner = await getKV(`car_${id.toLowerCase()}`); 163 | if (!owner) { 164 | return getResponse(JSON.stringify({ code: 500, data: "车辆信息错误!", message: "fail" }), 200); 165 | } 166 | if(!owner.isNotify){ 167 | return getResponse(JSON.stringify({ code: 500, data: "车主未开启该功能,请使用其他方式联系车主!", message: "fail" }), 200); 168 | } 169 | let resp = null; 170 | const { no, notifyType, notifyToken } = owner; 171 | const provider = notifyTypeMap.find(element => element.id == notifyType); 172 | if (provider && provider.functionName && typeof provider.functionName === 'function') { 173 | const sendMsg = `【${no}】${message || notifyMessage}`; 174 | resp = await provider.functionName(notifyToken, sendMsg); 175 | } 176 | else { 177 | resp = { code: 500, data: "发送失败!", message: "fail" }; 178 | } 179 | return getResponse(JSON.stringify(resp), 200); 180 | } 181 | 182 | async function callOwner(json) { 183 | const { id } = json; 184 | const owner = await getKV(`car_${id.toLowerCase()}`); 185 | if (!owner) { 186 | return getResponse(JSON.stringify({ code: 500, data: "车辆信息错误!", message: "fail" }), 200); 187 | } 188 | if(!owner.isCall){ 189 | return getResponse(JSON.stringify({ code: 500, data: "车主未开启该功能,请使用其他方式联系车主!", message: "fail" }), 200); 190 | } 191 | const { phone } = owner; 192 | return getResponse(JSON.stringify({ code: 200, data: phone, message: "success" }), 200); 193 | } 194 | 195 | async function addOwner(json) { 196 | try { 197 | const { id, no, phone, notifyType, notifyToken, isNotify, isCall } = json; 198 | await putKV(`car_${id.toLowerCase()}`, { id, no, phone, notifyType, notifyToken, isNotify, isCall }); 199 | return getResponse(JSON.stringify({ code: 200, data: "添加成功", message: "success" }), 200); 200 | } catch (e) { 201 | return getResponse(JSON.stringify({ code: 500, data: "添加失败," + e.message, message: "success" }), 200); 202 | } 203 | } 204 | 205 | async function deleteOwner(json) { 206 | try { 207 | const { id } = json; 208 | await delKV(`car_${id.toLowerCase()}`); 209 | return getResponse(JSON.stringify({ code: 200, data: "删除成功", message: "success" }), 200); 210 | } catch (e) { 211 | return getResponse(JSON.stringify({ code: 500, data: "删除失败," + e.message, message: "success" }), 200); 212 | } 213 | } 214 | 215 | async function listOwner() { 216 | const value = await listKV("car_", 50); 217 | const keys = value.keys; 218 | const arrys = []; 219 | for (let i = 0; i < keys.length; i++) { 220 | const owner = await getKV(keys[i].name); 221 | if (!owner || !owner?.id) { 222 | continue; 223 | } 224 | arrys.push(owner); 225 | } 226 | return getResponse(JSON.stringify({ code: 200, data: arrys, message: "success" }), 200); 227 | } 228 | 229 | function getNotifyTypeList() { 230 | const types = []; 231 | notifyTypeMap.forEach(element => { 232 | types.push({ text: element.name, value: element.id, tip: element.tip }) 233 | }); 234 | 235 | return getResponse(JSON.stringify({ code: 200, data: types, message: "success" }), 200); 236 | } 237 | 238 | function login() { 239 | const htmlContent = ` 240 | 241 | 242 | 243 | 244 | 246 | 通知车主挪车 247 | 375 | 376 | 377 | 378 |
379 |

登录

380 | 381 | 382 |
383 |
384 | 387 | 388 | 445 | 446 | 447 | `; 448 | 449 | return new Response(htmlContent, { 450 | headers: { 451 | 'Content-Type': 'text/html;charset=UTF-8', 452 | 'Access-Control-Allow-Origin': '*', 453 | 'Access-Control-Allow-Headers': '*' 454 | } 455 | }) 456 | } 457 | 458 | async function index1(id) { 459 | const owner = await getKV(`car_${id.toLowerCase()}`); 460 | const isNotify = owner?.isNotify ?? true; 461 | const isCall = owner?.isCall ?? true; 462 | 463 | const htmlContent = ` 464 | 465 | 466 | 467 | 468 | 470 | 通知车主挪车 471 | 600 | 601 | 602 | 603 |
604 |

通知车主挪车

605 |

如需通知车主,请点击以下按钮

606 | 607 | 608 |
609 |
610 | 613 | 614 | 708 | 709 | 710 | `; 711 | 712 | return new Response(htmlContent, { 713 | headers: { 714 | 'Content-Type': 'text/html;charset=UTF-8', 715 | 'Access-Control-Allow-Origin': '*', 716 | 'Access-Control-Allow-Headers': '*' 717 | } 718 | }) 719 | } 720 | 721 | async function index2(id) { 722 | const owner = await getKV(`car_${id.toLowerCase()}`); 723 | const isNotify = owner?.isNotify ?? true; 724 | const isCall = owner?.isCall ?? true; 725 | 726 | const htmlContent = ` 727 | 728 | 729 | 730 | 731 | 733 | 通知车主挪车 734 | 944 | 945 | 946 | 947 |
948 |
🚗
949 |

温馨提示

950 |

不好意思阻碍到您的出行了
请通过以下方式联系我,我会立即前来挪车

951 |
952 | 953 |
954 |
955 | 958 | 961 |
962 |
963 | 966 | 969 |
970 |
971 | 974 | 977 |
978 |
979 |
980 | 983 | 1086 | 1087 | 1088 | `; 1089 | return new Response(htmlContent, { 1090 | headers: { 1091 | 'Content-Type': 'text/html;charset=UTF-8', 1092 | 'Access-Control-Allow-Origin': '*', 1093 | 'Access-Control-Allow-Headers': '*' 1094 | } 1095 | }) 1096 | } 1097 | 1098 | function managerOwnerIndex() { 1099 | const htmlContent = ` 1100 | 1101 | 1102 | 1103 | 1104 | 1105 | 车辆管理系统 1106 | 1319 | 1320 | 1321 | 1322 |
1323 |
1324 |

车辆管理系统

1325 | 1326 | 1327 | 1328 |
1329 |
1330 | 1331 | 1332 | 1333 | 1334 | 1335 | 1336 | 1337 | 1338 | 1339 | 1340 | 1341 | 1342 | 1343 | 1344 | 1345 |
车辆ID车牌号手机号通知方式通知Token消息通知电话通知操作
1346 |
1347 |
1348 | 1384 | 1387 | 1388 | 1688 | 1689 | 1690 | `; 1691 | 1692 | return new Response(htmlContent, { 1693 | headers: { 1694 | 'Content-Type': 'text/html;charset=UTF-8', 1695 | 'Access-Control-Allow-Origin': '*', 1696 | 'Access-Control-Allow-Headers': '*' 1697 | } 1698 | }) 1699 | } 1700 | 1701 | async function wxpusher(token, message) { 1702 | const tokens = token.split('|'); 1703 | const reqUrl = 'https://wxpusher.zjiecode.com/api/send/message'; 1704 | const jsonBody = { 1705 | appToken: `${tokens[0]}`, 1706 | uids: [`${tokens[1]}`], 1707 | content: `${message}`, 1708 | contentType: 1 1709 | } 1710 | const response = await postRequest(reqUrl, jsonBody); 1711 | const json = await response.json(); 1712 | const { code } = json; 1713 | if (code == 1000) { 1714 | return { code: 200, data: sendSuccessMessage, message: "success" }; 1715 | } 1716 | else { 1717 | return { code: 500, data: "通知发送失败,请稍后重试。", message: "fail" }; 1718 | } 1719 | } 1720 | 1721 | async function bark(token, message) { 1722 | const tokens = token.split('|'); 1723 | const reqUrl = 'https://api.day.app/push'; 1724 | const jsonBody = { 1725 | "body": message, 1726 | "title": "挪车通知", 1727 | "device_key": tokens[0] || "", 1728 | "sound": tokens[1] || "multiwayinvitation", 1729 | "group": "挪车通知", 1730 | "call": "1" 1731 | } 1732 | 1733 | const response = await postRequest(reqUrl, jsonBody); 1734 | const json = await response.json(); 1735 | const { code } = json; 1736 | if (code == 200) { 1737 | return { code: 200, data: sendSuccessMessage, message: "success" } 1738 | } 1739 | else { 1740 | return { code: 500, data: "通知发送失败,请稍后重试。", message: "fail" }; 1741 | } 1742 | } 1743 | 1744 | async function feishu(token, message) { 1745 | const reqUrl = `https://open.feishu.cn/open-apis/bot/v2/hook/${token}`; 1746 | const jsonBody = { 1747 | "msg_type": "text", 1748 | "content": { 1749 | "text": message 1750 | } 1751 | } 1752 | const response = await postRequest(reqUrl, jsonBody); 1753 | const json = await response.json(); 1754 | const { code } = json; 1755 | if (code == 0) { 1756 | return { code: 200, data: sendSuccessMessage, message: "success" }; 1757 | } 1758 | else { 1759 | return { code: 500, data: "通知发送失败,请稍后重试。", message: "fail" }; 1760 | } 1761 | } 1762 | 1763 | async function weixin(token, message) { 1764 | const reqUrl = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${token}`; 1765 | const jsonBody = { 1766 | "msgtype": "text", 1767 | "text": { 1768 | "content": message 1769 | } 1770 | } 1771 | const response = await postRequest(reqUrl, jsonBody); 1772 | const json = await response.json(); 1773 | const { errcode } = json; 1774 | if (errcode == 0) { 1775 | return { code: 200, data: sendSuccessMessage, message: "success" }; 1776 | } 1777 | else { 1778 | return { code: 500, data: "通知发送失败,请稍后重试。", message: "fail" }; 1779 | } 1780 | } 1781 | 1782 | async function dingtalk(token, message) { 1783 | const reqUrl = `https://oapi.dingtalk.com/robot/send?access_token=${token}`; 1784 | const jsonBody = { 1785 | "msgtype": "text", 1786 | "text": { 1787 | "content": message 1788 | } 1789 | } 1790 | const response = await postRequest(reqUrl, jsonBody); 1791 | const json = await response.json(); 1792 | const { errcode } = json; 1793 | if (errcode == 0) { 1794 | return { code: 200, data: sendSuccessMessage, message: "success" }; 1795 | } 1796 | else { 1797 | return { code: 500, data: "通知发送失败,请稍后重试。", message: "fail" }; 1798 | } 1799 | } 1800 | 1801 | async function onebot(token, message) { 1802 | const tokens = token.split('|'); 1803 | const reqUrl = tokens[0]; 1804 | const access_token = tokens[1]; 1805 | const uid = tokens[2]; 1806 | const jsonBody = { 1807 | "message": message 1808 | } 1809 | 1810 | if (reqUrl.includes("send_private_msg")) { 1811 | jsonBody["user_id"] = uid; 1812 | } 1813 | else { 1814 | jsonBody["group_id"] = uid; 1815 | } 1816 | 1817 | const headers = { "Authorization": `Bearer ${access_token}` }; 1818 | const response = await postRequest(reqUrl, jsonBody, headers); 1819 | const json = await response.json(); 1820 | const { retcode } = json; 1821 | if (retcode == 0) { 1822 | return { code: 200, data: sendSuccessMessage, message: "success" }; 1823 | } 1824 | else { 1825 | return { code: 500, data: "通知发送失败,请稍后重试。", message: "fail" }; 1826 | } 1827 | } 1828 | 1829 | function getResponse(resp, status = 200, headers = {}) { 1830 | return new Response(resp, { 1831 | status: status, 1832 | headers: { 1833 | 'Content-Type': 'application/json', 1834 | 'Access-Control-Allow-Origin': '*', 1835 | 'Access-Control-Allow-Headers': '*', 1836 | ...headers 1837 | } 1838 | }); 1839 | } 1840 | 1841 | async function postRequest(reqUrl, jsonBody, headers) { 1842 | const response = await fetch(reqUrl, { 1843 | method: 'POST', 1844 | headers: { 1845 | 'Content-Type': 'application/json', 1846 | ...headers 1847 | }, 1848 | body: JSON.stringify(jsonBody) 1849 | }); 1850 | 1851 | if (!response.ok) { 1852 | throw new Error('Unexpected response ' + response.status); 1853 | } 1854 | return response; 1855 | } 1856 | --------------------------------------------------------------------------------