ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序云开发实现员工生日短信提醒:云函数+定时触发器全攻略

微信小程序云开发实现员工生日短信提醒:云函数+定时触发器全攻略

前阵子我接到一个挺有意思的需求:在微信小程序云开发上,对接阿里云短信服务,每天自动扫一遍员工信息,遇到当天生日的员工就给人家发祝福短信。接到需求的时候,团队里有人觉得这不就是一个定时任务加短信接口嘛,写俩函数就完了。等真正落地,才发现里面有很多细节,尤其是生日匹配、时区转换、定时触发和短信发送状态这四个环节,任何一个没处理好,都会出幺蛾子。

这篇文章我把完整方案和踩坑过程整理出来。内容会从需求拆解、环境准备、员工生日数据建模,到云函数发布、定时触发、短信发送与去重,最后落到常见问题排查。无论你是刚开始做小程序云开发,还是已经上手但没写过定时任务,都能直接照着抄。

1. 需求拆解与整体方案:为什么非要用云开发

1.1 这个需求到底在解决什么问题

表面上看是一个“到点发短信”的小功能,但实际上包含三件事:员工生日数据的存储与检索、定时任务触发、短信服务对接。如果放在传统服务器上,你至少要先有一台服务器、一个数据库、一个定时任务调度器,还要处理进程常驻、崩溃重启、日志采集等问题。

用微信小程序云开发,最大的好处是省掉了运维这一层。云数据库负责存员工信息,云函数负责跑逻辑,定时触发器负责“每天早上9点执行一次”,短信服务只管发。整个项目不需要买服务器、不需要配置公网IP、不需要自己写守护进程,对于“公司内部员工关怀”这类轻量业务,性价比很高。

1.2 技术选型的几个关键判断

我最终确定方案时,主要考虑了四点。

第一,为什么不用小程序端直接发请求?因为短信服务需要服务器端持有的密钥,如果在小程序前端代码里调用,密钥会被直接暴露,这种做法等于把短信通道拱手送人。所以短信发送逻辑必须放在云函数里,由云端调用阿里云短信服务接口。

第二,为什么用云函数而不是小程序云开发里的HTTP API?因为云函数可以拿到数据库操作权限,也能配置定时触发,天然适合这种“定时扫描数据库 + 调外部接口”的场景。HTTP API 还需要额外写路由逻辑,没必要。

第三,员工生日数据为什么要专门建一个“月日”字段?因为生日只会在每年同一天重复,SQL里常见的做法是MONTH(birthday) = 4 AND DAY(birthday) = 20,但在云数据库的 JSON 查询里,直接对日期字符串做函数运算和提取子串都很别扭,也没法走索引。所以我用了一个冗余字段birthdayMMDD,专门存“MM-DD”格式,查询时直接等值匹配,简单高效。

第四,为什么选阿里云短信服务?标题里已经限定了,而且阿里云短信服务在市场上很成熟,签名和模板审核流程清晰,API 稳定。微信小程序云开发是腾讯系产品,阿里云短信是阿里系产品,两边协议不同,但只要通过 HTTPS 调用就不存在兼容问题。云函数本质上是 Node.js 环境,使用官方 SDK 或者直接请求 OpenAPI 都行。

1.3 整体数据流

整个流程可以分成五步:

  1. 管理员在小程序端录入员工姓名、手机号、生日。
  2. 数据写入云数据库employees集合。
  3. 云函数在每天上午自动运行,根据当天日期计算MM-DD。
  4. 云函数查询当天过生日的员工列表。
  5. 云函数调用短信服务,给员工手机号发送祝福短信,并把发送结果写回数据库。

这五步看起来简单,但每一步都有值得注意的细节,下面逐个拆开说。

2. 环境准备与资源配置:先把地基打牢

2.1 开通小程序云开发环境

在动手写代码前,先把云开发环境准备好。路径是:小程序后台 → 云开发 → 开通。开通后你会得到一个环境 ID,比如cloud1-xxxxxx。

需要确认三件事:

  • 云数据库已创建,用于存放员工信息集合。
  • 云函数已创建,用于跑生日检索和发送逻辑。
  • 开发环境具备定时触发能力,这个通常在云函数配置里直接支持,不需要额外购买。

如果是个人开发,建议先用免费额度或按量付费环境测试。阿里云短信服务本身也是按条计费,测试阶段费用很低,但前提是别拿真实手机号瞎发。

2.2 申请阿里云短信签名与模板

阿里云短信服务不是直接拿 AccessKey 就能发短信,必须先申请签名和模板,审核通过后才能调用SendSms接口。

签名就是发短信时显示的“【公司名称】”这几个字。申请时要提供公司名称、营业执照或相关资质。如果是个人开发者,也可以申请个人签名,但审核会更严格。签名通过后,不能随意改,所有短信都会带上这个标识。

模板就是短信正文。生日祝福短信模板可以写成类似:

亲爱的${name},今天是您的生日,祝您生日快乐,身体健康,工作顺利!

需要特别注意模板变量名。模板里写的是name,那么代码里的TemplateParam就必须是{"name":"张三"}。如果模板写的是${userName},代码里传的参数键名也得一致,否则接口会返回“模板变量不匹配”之类的错误。

2.3 AccessKey 安全存放与权限控制

阿里云短信服务需要一对 AccessKey ID 和 AccessKey Secret 来调用接口。这对密钥是云函数访问短信服务的“身份证”,绝不能明文写在代码仓库里,更不能写在小程序前端。

我的做法是把密钥放到云函数的环境变量里。在云函数配置页面可以设置多个环境变量,例如:

ALIYUN_ACCESS_KEY_ID=你的AccessKeyId ALIYUN_ACCESS_KEY_SECRET=你的AccessKeySecret SMS_SIGN_NAME=你的签名 SMS_TEMPLATE_CODE=你的模板Code

云函数代码里通过process.env.ALIYUN_ACCESS_KEY_ID读取,这样代码本身不包含任何敏感信息,团队成员克隆代码也不会泄露密钥。

还有一个容易被忽略的点:给 AccessKey 设置最小权限。阿里云支持 RAM 子账号,建议单独创建一个子账号,只授权短信服务权限,不要用主账号 AccessKey。万一泄露,子账号的权限范围有限,风险可控。

3. 核心实现:员工生日数据的建模与检索

3.1 员工集合字段设计

数据库设计是这次方案里最影响后续开发的一部分。我建的employees集合,一条记录长这样:

{ "_id": "emp_001", "name": "张三", "phone": "13800138000", "birthday": "1990-05-20", "birthdayMMDD": "05-20", "department": "研发部", "status": "active", "lastNotifyYm": "2026-05-20" }

字段说明如下:

字段类型说明
namestring员工姓名
phonestring手机号,统一为 11 位国内号码
birthdaystring完整生日,格式 YYYY-MM-DD
birthdayMMDDstring生日月日,格式 MM-DD,用于查询
departmentstring部门信息
statusstringactive 表示在职可发送
lastNotifyYmstring最近一次发送通知的日期,用于去重

birthday和birthdayMMDD两个字段并存,是因为展示完整生日用前者,按日期检索用后者。

3.2 为什么不直接对生日字段做模糊搜索

曾有人问我,能不能直接这样写:

db.collection('employees').where({ birthday: db.RegExp({ regexp: '-05-20$', options: 'i' }) })

这个写法在数据量小的时候没问题,但本质上是对字符串做正则匹配,无法用到索引,而且查询性能会随着数据量增大而下降。更麻烦的是,这种写法不够直观,可读性差。

反过来,把birthdayMMDD作为独立字段,查询就变成了一次等值匹配:

const res = await db.collection('employees') .where({ birthdayMMDD: mmdd, status: 'active' }) .limit(100) .get()

这个查询逻辑非常清晰:今天是什么月日,我就去数据库里找所有birthdayMMDD等于这个月日的员工。配合索引后,速度很快。

3.3 获取“今天”的月日,必须处理时区

这是新手最容易踩的坑。微信小程序云函数的运行环境默认是 UTC 时区,而我们习惯使用的是东八区时间。如果直接new Date()然后取月份和日期,你会发现自己早上 9 点跑任务,得到的还是前一天的结果。

正确的做法是先做时区偏移,或者使用带时区的时间格式化。

我采用的写法是:

function getTodayMMDD() { const now = new Date() const localTime = new Date(now.getTime() + 8 * 60 * 60 * 1000) const month = localTime.getUTCMonth() + 1 const day = localTime.getUTCDate() return `${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` }

这里先把当前时间加上 8 个小时,然后再用getUTCMonth和getUTCDate读取,拿到的就是北京时间对应的月日。对于国内业务,这个方案足够。如果以后有海外员工,再按不同时区分别处理,但当前需求不需要。

3.4 查询结果的分页问题

云开发数据库单次get()有返回条数限制,默认是 20 条,最多可以设置到 100 条。如果员工人数超过 100,或者存在很多同一天生日的情况,就必须循环翻页。

建议写一个通用函数,把查询结果一次性捞全:

async function getAllBirthdayEmployees(mmd) { const db = cloud.database() const collection = db.collection('employees') const MAX_LIMIT = 100 const countResult = await collection .where({ birthdayMMDD: mmd, status: 'active' }) .count() const total = countResult.total const tasks = [] for (let i = 0; i < total; i += MAX_LIMIT) { const task = collection .where({ birthdayMMDD: mmd, status: 'active' }) .skip(i) .limit(MAX_LIMIT) .get() tasks.push(task) } const results = await Promise.all(tasks) return results.reduce((acc, cur) => acc.concat(cur.data), []) }

这个函数用于中小规模企业足够了。超过 100 人时也能正确取出全部记录,不会漏人。

4. 短信发送与定时触发:让任务自己跑起来

4.1 云函数里集成阿里云短信 SDK

云函数是 Node.js 环境,可以在函数目录下执行安装依赖的命令。因为我用的是阿里云短信服务,所以在package.json里加上依赖。示例:

npm install wx-server-sdk npm install @alicloud/pop-core

wx-server-sdk是微信云开发官方 SDK,用于操作云数据库。@alicloud/pop-core是阿里云的 RPC 调用工具,通过它可以调用短信发送接口,而不需要完整引入整个短信服务 SDK。

云函数代码大致如下:

const cloud = require('wx-server-sdk') const Core = require('@alicloud/pop-core') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const smsClient = new Core({ accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID, accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET, endpoint: 'https://dysmsapi.aliyuncs.com', apiVersion: '2017-05-25' }) async function sendBirthdaySms(employee) { const params = { PhoneNumbers: employee.phone, SignName: process.env.SMS_SIGN_NAME, TemplateCode: process.env.SMS_TEMPLATE_CODE, TemplateParam: JSON.stringify({ name: employee.name }) } const requestOption = { method: 'POST' } const response = await smsClient.request('SendSms', params, requestOption) return response } exports.main = async (event) => { const mmd = getTodayMMDD() const employees = await getAllBirthdayEmployees(mmd) const results = [] for (const emp of employees) { try { const resp = await sendBirthdaySms(emp) results.push({ name: emp.name, phone: emp.phone, code: resp.Code, message: resp.Message }) if (resp.Code === 'OK') { await db.collection('employees').doc(emp._id).update({ data: { lastNotifyYm: new Date().toISOString().slice(0, 10) } }) } } catch (err) { results.push({ name: emp.name, phone: emp.phone, error: err.message }) } } return { total: employees.length, results } }

这里有几个细节要说清楚。

第一,TemplateParam必须是 JSON 字符串,不能直接传对象。我一开始传了对象,接口返回参数格式错误,后来改成JSON.stringify才通过。

第二,发送成功不只看 HTTP 是否响应,还要看返回参数里的Code是否为OK。阿里云短信接口就算签名或模板有问题,也会返回 HTTP 200,但业务 Code 会是错误码,比如isv.SMS_TEMPLATE_ILLEGAL。我习惯在代码里判断Code === 'OK'再写回结果,这样才能准确记录发送状态。

第三,发送短信是外部调用,可能因为网络问题失败。云函数默认超时时间是 3 秒,但发送短信接口通常需要 1 到 3 秒,如果一次要给几十个人发,很容易超时。我建议把云函数超时时间调到 20 秒或更长,具体在云函数配置里修改。

4.2 配置定时触发器

云函数写好后,还需要配置定时触发器。下面是一个定时触发的配置片段:

{ "triggers": [ { "name": "dailyBirthdayCheck", "type": "timer", "config": "0 0 9 * * * *" } ] }

这个 Cron 表达式是云开发定时触发器的固定格式,一共 7 位,分别表示秒、分、时、日、月、星期、年。0 0 9 * * * *表示每天上午 9 点 0 分 0 秒执行一次。

配置时要注意:

  • 云函数需要部署后,触发器才会生效。
  • 触发器执行的是云函数的main入口,所以event里不会有定时任务参数,代码里要做兼容处理。
  • 配置修改后,最好先在控制台“云端测试”手动运行一次,确认没有问题再等定时任务触发。

因为云函数默认时区可能是 UTC,而 Cron 表达式里的小时通常按服务器时区解释。我在实践中发现,直接在 Cron 里写 9 点,执行时可能是北京时间 17 点,具体情况要看所部署云函数的时区设置。为了稳妥,我一般把触发时间设成凌晨或者上午的低峰期,并且在云函数内部自己计算北京时间执行逻辑,而不是依赖 Cron 的时区。

如果发现定时执行的时间不对,可以在云函数代码里加日志,把执行时间打出来。根据日志结果去调整 Cron 的小时位,这个操作比空想更高效。

4.3 去重:防止一年收到多条祝福短信

定时任务最怕重复执行。比如云函数超时后被平台重试,或者同一天手动运行了两次,员工就可能收到两遍生日短信,这是很尴尬的事。

我在数据模型里加了一个lastNotifyYm字段,记录最近一次发送通知的年月日。每次发送前,除了判断生日是不是今天,还要判断这个字段是否等于今天的日期:

const today = getTodayDate() // 返回 YYYY-MM-DD const employees = await getAllBirthdayEmployeesByDate(mmd, today)

查询条件变成:

.where({ birthdayMMDD: mmd, status: 'active', lastNotifyYm: _.neq(today) })

这样即使任务被重试,也只会给没收到过短信的人发送。发送成功后,再把lastNotifyYm更新为今天,避免下次重复触发。

这个方案的优点是简单可靠,缺点是如果员工当天第一次发送失败,后面的重试还会尝试发送,可能造成重复。所以我在代码里做了两阶段处理:先更新标记,再发送短信,还是先发送成功再更新标记?我最终选择的是成功后再更新标记,这样重试时,失败的记录不会被标记,可以补发。但为了绝对防止重复,也可以先标记,失败后人工处理。具体看你更在意“不重”还是“不漏”。

5. 常见问题与排查实录

5.1 生日判断错误:差一天

我调试的时候遇到过生日提前一天触发的情况。排查后发现是因为云函数读到的是 UTC 时间,当时是北京时间早上 9 点多,UTC 时间还是前一天。后来加了 8 小时偏移,问题解决。

排查技巧:在云函数最开始输出日志:

console.log('current time', new Date().toISOString()) console.log('local time', new Date(Date.now() + 8 * 3600 * 1000).toISOString())

看日志里两个时间差,基本能确认是不是时区问题。

5.2 短信接口返回“模板变量不匹配”

我申请模板时写的是${name},但是代码里TemplateParam传的是{"userName":"张三"},接口立刻返回错误。原因是模板变量名必须和参数键名一一对应。

解决方法是认真核对模板内容里的每一个变量:

  • 模板里${name},代码里就传name。
  • 模板里${phone},代码里就传phone。
  • 如果有多个变量,TemplateParam里全部带上。

5.3 云函数执行成功但短信没收到

这种情况最容易误判。云函数返回了成功,但短信服务返回的业务 Code 不一定是 OK。我建议把短信接口的完整返回都打印到日志里,不要只看是否 HTTP 200。

常见返回错误码和原因如下:

返回码含义处理方式
OK发送成功无需处理
isv.SMS_TEMPLATE_ILLEGAL模板不合法检查模板审核状态和内容
isv.SMS_SIGNATURE_ILLEGAL签名不合法检查签名审核状态
isv.MOBILE_NUMBER_ILLEGAL手机号格式错误检查手机号是否 11 位
isv.BUSINESS_LIMIT_CONTROL触发发送频率限制降低单次发送数量或调整频次

建议每次发送都记录员工ID、手机号、发送返回 Code、返回 Message,方便事后追踪。

5.4 定时触发器不生效

有朋友遇到过触发器配置了,但到点不执行。排查方向有两个:

一是触发器是否和云函数正确关联。某些云开发版本需要在云函数详情页单独新建定时触发,而不是只改配置文件。二是云函数是否已部署。配置文件改了,但云函数没有重新部署,触发器自然不生效。

我自己的习惯是:改代码后先手动云端测试一次,确认输出正常,再修改触发器配置,最后部署。部署完成后,去日志里看是否产生了新的执行记录。

6. 一些踩坑后的经验补充

6.1 先把发送动作做成可手动触发的接口

定时任务开发阶段,不建议直接等 Cron 触发。我会在云函数里支持一个手动触发模式:

if (event.action === 'manual') { // 手动指定员工手机号和姓名发送测试短信 }

这样在小程序云开发控制台就能直接调用云函数,传入一个测试手机号,验证短信通道是否联通。等手动验证通过,再接定时触发器。

6.2 测试时使用自己的手机号,别拿同事当小白鼠

短信发出去了很难撤回。测试阶段一定要使用自己可控的手机号,并提前告知对方会收到测试短信。我当时用了一个真实手机号测了很久,结果因为模板里有错别字,发出去一条,特别尴尬。

更稳妥的办法是先在云函数里加一个环境变量TEST_MOBILE,当处于测试模式时,无论查出来多少员工,只往这个测试手机号发送,并且日志里打印原始手机号。上线前去掉或切换。

6.3 发送量大的时候要控制并发

如果公司有几百人,同一天生日可能就几个人,问题不大。但如果是做全员通知,几百条短信同时发,就可能触发阿里云的频率限制。我的建议是根据实际情况分批发送,比如每批 20 条,批与批之间间隔几秒,而不是把所有手机号一次性塞给短信接口。

云函数里可以这样控制:

const batchSize = 20 for (let i = 0; i < employees.length; i += batchSize) { const batch = employees.slice(i, i + batchSize) await Promise.all(batch.map(sendBirthdaySms)) await new Promise(resolve => setTimeout(resolve, 1000)) }

注意云函数的内存和超时时间要配套调整。如果员工多,建议把云函数内存设置为 512MB 或更高,超时时间设置为 60 秒。

6.4 可以扩展的玩法

做完这个项目后,我发现这套模式完全可以扩展:员工入职周年纪念通知、合同到期提醒、节假日问候、客户生日关怀。只要把birthdayMMDD换成anniversaryMMDD,或者加一个“提醒类型”字段,同一套定时触发 + 云函数 + 短信服务的骨架就能复用。

我个人在实际操作中的体会是,这种业务真正复杂的地方不在短信接口本身,而在于“怎么准确判断今天该给谁发、怎么避免重复、怎么把失败情况查清楚”。把这三点想明白,换任何短信服务商,代码都只需要改动很小一部分。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进