// 云对象教程: https://uniapp.dcloud.net.cn/uniCloud/cloud-obj // jsdoc语法提示教程:https://ask.dcloud.net.cn/docs/#//ask.dcloud.net.cn/article/129 const createConfig = require('uni-config-center'); const buildTemplateData = require('./build-template-data'); const { parserDynamicField } = require('./utils'); const schemaNameAdapter = require('./schema-name-adapter'); const { presetCondition, conditionConvert } = require('./preset-condition'); const uniSmsCo = uniCloud.importObject('uni-sms-co'); const db = uniCloud.database(); const smsConfig = createConfig({ pluginId: 'uni-sms-co' }).config(); function errCode(code) { return 'uni-sms-co-' + code; } function errorResult(code, message, extra = {}) { return { errCode: errCode(code), errMsg: message, ...extra }; } function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function hasValue(value) { return value !== undefined && value !== null; } function isId(value) { return (typeof value === 'string' && value.trim().length > 0) || (typeof value === 'number' && Number.isFinite(value)); } function validateTemplateData(templateData) { if (templateData === undefined || templateData === null) templateData = []; if (!Array.isArray(templateData)) { return { error: errorResult('template-data-invalid', 'templateData必须是数组') }; } const normalized = []; for (const item of templateData) { if (!isPlainObject(item) || typeof item.field !== 'string' || !item.field.trim()) { return { error: errorResult('template-data-invalid', '短信模板变量必须包含有效的field') }; } // 0 和空字符串都是有意义的变量值;仅拒绝缺失/null,避免隐式转换造成数据丢失。 if (!hasValue(item.value) || !['string', 'number', 'boolean'].includes(typeof item.value) || (typeof item.value === 'number' && !Number.isFinite(item.value))) { return { error: errorResult('template-data-invalid', `短信变量${item.field}的value无效`) }; } normalized.push({ ...item, field: item.field.trim() }); } return { data: normalized }; } function validateCondition(condition) { if (!isPlainObject(condition)) { return errorResult('condition-invalid', '发送条件必须是对象'); } for (const key of Object.keys(condition)) { const field = condition[key]; if (!isPlainObject(field) || typeof field.type !== 'string' || !hasValue(field.value)) { return errorResult('condition-invalid', `发送条件字段${key}格式无效`); } if (!['search', 'select', 'range', 'date', 'timestamp', 'lt'].includes(field.type)) { return errorResult('condition-invalid', `不支持的发送条件类型:${field.type}`); } if (['select', 'range', 'date', 'timestamp'].includes(field.type) && (!Array.isArray(field.value) || field.value.length === 0)) { return errorResult('condition-invalid', `发送条件字段${key}的value不能为空`); } } return null; } function normalizeTarget(to, requireTarget = true) { if (!isPlainObject(to)) { return { error: errorResult('send-target-invalid', '发送目标to必须是对象') }; } const type = to.type === undefined ? 'user' : to.type; if (type !== 'user' && type !== 'userTags') { return { error: errorResult('send-target-invalid', '发送目标type必须是user或userTags') }; } if (to.receiver !== undefined && !Array.isArray(to.receiver)) { return { error: errorResult('send-target-invalid', '发送目标receiver必须是数组') }; } const receiver = Array.isArray(to.receiver) ? to.receiver : []; if (receiver.some(item => !isId(item))) { return { error: errorResult('send-target-invalid', '发送目标receiver包含无效ID') }; } let condition; let hasCondition = false; if (typeof to.condition === 'string') { const conditionName = to.condition.trim(); if (!conditionName || !Object.prototype.hasOwnProperty.call(presetCondition, conditionName)) { return { error: errorResult('condition-invalid', '预设发送条件无效') }; } condition = typeof presetCondition[conditionName] === 'function' ? presetCondition[conditionName]() : presetCondition[conditionName]; hasCondition = true; } else if (to.condition !== undefined && to.condition !== null) { if (!isPlainObject(to.condition)) { return { error: errorResult('condition-invalid', '发送条件必须是预设名称或对象') }; } const conditionError = validateCondition(to.condition); if (conditionError) return { error: conditionError }; condition = to.condition; hasCondition = Object.keys(condition).length > 0; } if (type === 'userTags' && receiver.length === 0) { return { error: errorResult('send-users-is-null', '请选择要发送的用户标签') }; } if (requireTarget && receiver.length === 0 && !hasCondition) { return { error: errorResult('send-users-is-null', '请选择要发送的用户') }; } return { data: { ...to, type, receiver, ...(hasCondition ? { condition } : {}) } }; } function validateTemplateId(templateId) { if (!isId(templateId)) return errorResult('template-id-required', '缺少有效的templateId'); return null; } const tableNames = { template: 'opendb-sms-template', task: 'opendb-sms-task', log: 'opendb-sms-log' }; module.exports = { _before: async function () { if (!smsConfig.smsKey || smsConfig.smsKey.length <= 20 || !smsConfig.smsSecret || smsConfig.smsSecret.length <= 20) { throw new Error('请先配置smsKey和smsSecret'); } this.tableNames = tableNames; await schemaNameAdapter.call(this); }, _after: function (error, result) { if (!error) return result; console.error(error); if (error instanceof Error) return errorResult('error', error.message); if (error && error.errCode) { return { ...error, errMsg: error.errMsg || error.message || '请求处理失败' }; } if (error && typeof error === 'object') { return errorResult('error', error.errMsg || error.message || '请求处理失败'); } return errorResult('error', '请求处理失败'); }, /** 创建短信任务 */ async createSmsTask(to, templateId, templateData, options = {}) { const templateIdError = validateTemplateId(templateId); if (templateIdError) return templateIdError; const target = normalizeTarget(to); if (target.error) return target.error; const vars = validateTemplateData(templateData); if (vars.error) return vars.error; if (options === null || options === undefined) options = {}; if (!isPlainObject(options)) return errorResult('task-options-invalid', '任务options必须是对象'); if (options.taskName !== undefined && (typeof options.taskName !== 'string' || !options.taskName.trim())) { return errorResult('task-name-invalid', '任务名称不能为空'); } const normalizedTemplateId = String(templateId).trim(); const { data: templates = [] } = await db.collection(this.tableNames.template) .where({ _id: normalizedTemplateId }).get(); if (!Array.isArray(templates) || templates.length === 0) { return errorResult('template-not-found', '短信模板不存在'); } const [template] = templates; if (!template || typeof template.content !== 'string' || typeof template.sign !== 'string') { return errorResult('template-invalid', '短信模板内容或签名无效'); } const clientInfo = this.getClientInfo && this.getClientInfo() || {}; const task = await db.collection(this.tableNames.task).add({ app_id: clientInfo.appId, name: options.taskName || '', template_id: normalizedTemplateId, template_content: template.content, vars: vars.data, to: target.data, send_qty: 0, success_qty: 0, fail_qty: 0, create_date: Date.now() }); uniSmsCo.createUserSmsMessage(task.id); return new Promise(resolve => setTimeout(() => resolve({ errCode: 0, errMsg: '任务创建成功', taskId: task.id }), 300)); }, async createUserSmsMessage(taskId, execData = {}) { if (!isId(taskId)) return errorResult('task-id-required', '缺少有效的任务ID'); if (!isPlainObject(execData)) return errorResult('exec-data-invalid', '执行参数必须是对象'); const parallel = 100; let beforeId; const { data: tasks = [] } = await db.collection(this.tableNames.task) .where({ _id: String(taskId) }).get(); if (!Array.isArray(tasks) || tasks.length === 0) { return errorResult('task-id-not-found', '任务ID不存在'); } const [task] = tasks; const storedTarget = normalizeTarget(task && task.to, true); if (storedTarget.error || !task || !isPlainObject(task.to)) { return errorResult('task-invalid', '短信任务的发送目标无效'); } const taskTo = { ...storedTarget.data }; const receiver = Array.isArray(taskTo.receiver) ? taskTo.receiver : []; const vars = task.vars === undefined || task.vars === null ? [] : task.vars; const varsResult = validateTemplateData(vars); if (varsResult.error) return errorResult('task-invalid', '短信任务的vars无效'); let query = { mobile: db.command.exists(true) }; if (taskTo.type === 'user' && receiver.length > 0 && !taskTo.condition) { let index = 0; if (isId(execData.beforeId)) { const i = receiver.findIndex(id => String(id) === String(execData.beforeId)); index = i !== -1 ? i + 1 : 0; } const currentReceiver = receiver.slice(index, index + parallel); // 批次耗尽时使用空集合查询,继续走统一的收尾逻辑,避免从头递归发送。 query._id = db.command.in(currentReceiver); if (currentReceiver.length > 0) beforeId = currentReceiver[currentReceiver.length - 1]; } if (taskTo.type === 'userTags') query.tags = db.command.in(receiver); if (taskTo.condition && Object.keys(taskTo.condition).length > 0) { query = { ...query, ...conditionConvert(taskTo.condition, db.command) }; } if ((taskTo.condition && Object.keys(taskTo.condition).length > 0 || taskTo.type === 'userTags') && isId(execData.beforeId)) { query._id = db.command.gt(execData.beforeId); } const dynamicField = parserDynamicField(varsResult.data); const userFields = dynamicField['uni-id-users'] ? dynamicField['uni-id-users'].reduce((res, field) => { res[field] = true; return res; }, {}) : {}; const { data: users = [] } = await db.collection('uni-id-users') .where(query) .field({ mobile: true, ...userFields }) .limit(parallel) .orderBy('_id', 'asc') .get(); if (!Array.isArray(users) || users.length === 0) { const count = await db.collection(this.tableNames.log).where({ task_id: taskId }).count(); await db.collection(this.tableNames.task).where({ _id: taskId }).update({ send_qty: count.total }); uniSmsCo.sendSms(taskId); return new Promise(resolve => setTimeout(() => resolve({ errCode: 0, errMsg: '创建完成' }), 500)); } if (!beforeId) beforeId = users[users.length - 1]._id; const docs = []; for (const user of users) { const varData = buildTemplateData(varsResult.data, user || {}); docs.push({ uid: user._id, task_id: taskId, mobile: user.mobile, var_data: varData, status: 0, create_date: Date.now() }); } await db.collection(this.tableNames.log).add(docs); uniSmsCo.createUserSmsMessage(taskId, { beforeId }); return new Promise(resolve => setTimeout(() => resolve(), 500)); }, async sendSms(taskId) { if (!isId(taskId)) return errorResult('task-id-required', '缺少有效的任务ID'); const { data: tasks = [] } = await db.collection(this.tableNames.task) .where({ _id: String(taskId) }).get(); if (!Array.isArray(tasks) || tasks.length === 0) { return errorResult('task-id-not-found', '任务ID不存在'); } const [task] = tasks; const vars = task.vars === undefined || task.vars === null ? [] : task.vars; const varsResult = validateTemplateData(vars); if (varsResult.error) return errorResult('task-invalid', '短信任务的vars无效'); const isStaticTemplate = varsResult.data.length === 0; const sendData = { appId: task.app_id, smsKey: smsConfig.smsKey, smsSecret: smsConfig.smsSecret, templateId: task.template_id, data: {} }; const { data: records = [] } = await db.collection(this.tableNames.log) .where({ task_id: taskId, status: 0 }) .limit(isStaticTemplate ? 50 : 1) .field({ mobile: true, var_data: true }) .get(); if (!Array.isArray(records) || records.length === 0) { return { errCode: 0, errMsg: '发送完成' }; } if (isStaticTemplate) { sendData.phoneList = records.map(record => record.mobile); } else { const [record] = records; sendData.phone = record.mobile; sendData.data = record.var_data || {}; } try { await uniCloud.sendSms(sendData); await db.collection(this.tableNames.log).where({ _id: db.command.in(records.map(record => record._id)) }).update({ status: 1, send_date: Date.now() }); await db.collection(this.tableNames.task).where({ _id: taskId }).update({ success_qty: db.command.inc(records.length) }); } catch (e) { console.error('[sendSms Fail]', e); await db.collection(this.tableNames.log).where({ _id: db.command.in(records.map(record => record._id)) }).update({ status: 2, reason: e && (e.errMsg || e.message) || '未知原因', send_date: Date.now() }); await db.collection(this.tableNames.task).where({ _id: taskId }).update({ fail_qty: db.command.inc(records.length) }); return errorResult('send-failed', e && (e.errMsg || e.message) || '短信发送失败'); } uniSmsCo.sendSms(taskId); return new Promise(resolve => setTimeout(() => resolve(), 500)); }, async template() { const { data: templates = [] } = await db.collection(this.tableNames.template).get(); return Array.isArray(templates) ? templates : []; }, async task(id) { if (!isId(id)) return errorResult('task-id-required', '缺少有效的任务ID'); const { data: tasks = [] } = await db.collection(this.tableNames.task) .where({ _id: String(id) }).get(); if (!Array.isArray(tasks) || tasks.length === 0) { return errorResult('task-id-not-found', '任务ID不存在'); } return tasks[0]; }, async updateTemplates(templates) { if (!Array.isArray(templates) || templates.length === 0) { return errorResult('template-is-null', '缺少模板信息'); } const normalized = []; for (const template of templates) { if (!isPlainObject(template) || !isId(template.templateId) || typeof template.templateName !== 'string' || !template.templateName.trim() || typeof template.templateContent !== 'string' || !template.templateContent.trim() || typeof template.templateType !== 'string' || !template.templateType.trim() || typeof template.templateSign !== 'string' || !template.templateSign.trim()) { return errorResult('template-invalid', '短信模板字段不完整或格式无效'); } normalized.push({ id: String(template.templateId).trim(), name: template.templateName.trim(), content: template.templateContent, type: template.templateType, sign: template.templateSign }); } await Promise.all(normalized.map(template => db.collection(this.tableNames.template) .doc(template.id).set({ name: template.name, content: template.content, type: template.type, sign: template.sign }))); return { errCode: 0, errMsg: '更新成功' }; }, async preview(to, templateId, templateData, options = {}) { const templateIdError = validateTemplateId(templateId); if (templateIdError) return templateIdError; const target = normalizeTarget(to); if (target.error) return target.error; const vars = validateTemplateData(templateData); if (vars.error) return vars.error; const normalizedTemplateId = String(templateId).trim(); const { data: templates = [] } = await db.collection(this.tableNames.template) .where({ _id: normalizedTemplateId }).get(); if (!Array.isArray(templates) || templates.length === 0) { return errorResult('template-not-found', '模板不存在'); } const [template] = templates; if (!template || typeof template.content !== 'string' || typeof template.sign !== 'string') { return errorResult('template-invalid', '短信模板内容或签名无效'); } const count = 1; let query = { mobile: db.command.exists(true) }; const normalizedTo = target.data; if (normalizedTo.type === 'user' && normalizedTo.receiver.length > 0 && !normalizedTo.condition) { query._id = db.command.in(normalizedTo.receiver); } if (normalizedTo.type === 'userTags') query.tags = db.command.in(normalizedTo.receiver); if (normalizedTo.condition && Object.keys(normalizedTo.condition).length > 0) { query = { ...query, ...conditionConvert(normalizedTo.condition, db.command) }; } const { data: users = [] } = await db.collection('uni-id-users').where(query).limit(count).get(); const { total = 0 } = await db.collection('uni-id-users').where(query).count(); if (!Array.isArray(users) || users.length === 0) { return errorResult('users-is-null', '请添加要发送的用户'); } const docs = users.map(user => { const varData = buildTemplateData(vars.data, user || {}); const content = template.content.replace(/\$\{(.*?)\}/g, ($1, param) => { if (!Object.prototype.hasOwnProperty.call(varData, param)) return $1; const value = varData[param]; return value === undefined || value === null ? $1 : String(value); }); return `【${template.sign}】${content}`; }); return { errCode: 0, errMsg: '', list: docs, total }; } };