const crypto = require("crypto"); const client = require("./client"); const { getDatabase } = require("../mongodb"); const { defaults, credentials, verification } = require("./config"); const COLLECTION_NAME = "sms-verification"; let ensureIndexesPromise = null; exports.main = async (event) => { try { switch (event.type) { case "sendVerificationCode": return await sendVerificationCode(event); case "verifyVerificationCode": return await verifyVerificationCode(event); case "sendMasSms": return await sendPlainSms(event); case "sendMasTemplateSms": return await sendTemplateSms(event); case "fetchMasReport": return await fetchReport(event); case "fetchMasDeliver": return await fetchDeliver(event); default: return { success: false, message: "未找到对应的方法", }; } } catch (error) { return { success: false, message: error.message || "请求失败", }; } }; /** * 发送验证码(新逻辑:使用 corp-member 表的 phone 字段) * 不再向下兼容旧逻辑 */ async function sendVerificationCode(event) { // 1. 参数校验 const corpId = typeof event.corpId === "string" ? event.corpId.trim() : ""; const loginMobile = normalizeSingleMobile(event.mobile); if (!corpId) { return { success: false, message: "corpId 不能为空" }; } if (!loginMobile) { return { success: false, message: "登录账号不能为空" }; } try { // 2. 查询 corp-member 表,获取 phone 字段 const { getDatabase } = require("../mongodb"); const corpDB = await getDatabase("corp"); const account = await corpDB.collection("corp-member").findOne( { corpId, mobile: loginMobile }, { projection: { phone: 1, _id: 1 } } ); if (!account) { return { success: false, message: "账号不存在" }; } const phone = typeof account.phone === "string" ? account.phone.trim() : ""; if (!phone) { return { success: false, message: "账号未绑定手机号,请联系管理员" }; } // 3. 验证码生成和存储逻辑 const collection = await getVerificationCollection(); const codeLength = Number.isInteger(event.codeLength) ? event.codeLength : verification.codeLength; const code = generateVerificationCode(codeLength); console.log("====>code", code); const nowTs = Date.now(); const expireAt = nowTs + verification.expireSeconds * 1000; let previousDoc = null; let docId = null; const current = await collection.findOne({ mobile: phone }); console.log("====>current", current); if (current) { previousDoc = current; // 检查频率限制 const waitSeconds = calculateWaitSeconds(current, nowTs); if (waitSeconds > 0) { return { success: false, message: `发送频繁,请${waitSeconds}s后重试`, data: { waitSeconds }, }; } console.log("====>waitSeconds", waitSeconds); // 更新已有记录 const u = await collection.updateOne( { _id: current._id }, { $set: { code: code, expireAt, lastSentAt: nowTs, updatedAt: nowTs, verifiedAt: null, attempts: 0, expireAtDate: new Date(expireAt), }, $inc: { sendCount: 1 }, } ); console.log("====>u", u); docId = current._id; } else { // 插入新文档 const insertDoc = { mobile: phone, corpId, loginMobile, code: code, expireAt, lastSentAt: nowTs, updatedAt: nowTs, verifiedAt: null, attempts: 0, createdAt: nowTs, sendCount: 1, expireAtDate: new Date(expireAt), }; console.log("====>insertDoc", insertDoc); const ins = await collection.insertOne(insertDoc); console.log("====>ins", ins); docId = ins.insertedId; } // 4. 发送短信 const expireMinutes = Math.ceil(verification.expireSeconds / 60) || 1; const smsEvent = { ecName: event.ecName || credentials.ecName, apId: event.apId || credentials.apId, secretKey: event.secretKey || credentials.secretKey, sign: event.sign || credentials.sign, addSerial: event.addSerial !== undefined ? event.addSerial : credentials.addSerial, mobiles: phone, templateId: "e9e7e6a631ea4dfabffdb0476bc39bfb", params: [code, String(expireMinutes)], baseUrl: event.baseUrl || defaults.baseUrl, }; // 检查是否禁用网关 const { gatewayDisabled } = require("./config"); if (gatewayDisabled) { await collection.updateOne( { _id: docId }, { $set: { lastResponse: { skipped: true, reason: "sms gateway disabled" }, lastResponseAt: nowTs, updatedAt: nowTs, }, } ); return { success: true, message: "验证码已发送", data: { expireAt, resendInterval: verification.resendIntervalSeconds, skipped: true, phone: maskMobile(phone), }, }; } // 真实发送 try { const sendResult = await sendTemplateSms(smsEvent); const gatewayData = sendResult.data; if (!isGatewaySuccess(gatewayData)) { throw new Error(getGatewayMessage(gatewayData) || "短信发送失败"); } await collection.updateOne( { _id: docId }, { $set: { lastResponse: sanitizeGatewayResponse(gatewayData), lastResponseAt: nowTs, updatedAt: nowTs, }, } ); return { success: true, message: "验证码已发送", data: { expireAt, resendInterval: verification.resendIntervalSeconds, phone: maskMobile(phone), }, }; } catch (error) { await rollbackVerificationDocument(collection, docId, previousDoc); return { success: false, message: error.message || "短信发送失败", }; } } catch (error) { return { success: false, message: error.message || "发送验证码失败", }; } } /** * 验证验证码(新逻辑:使用 corp-member 表的 phone 字段) * 不再向下兼容旧逻辑 */ async function verifyVerificationCode(event) { // 1. 参数校验 const corpId = typeof event.corpId === "string" ? event.corpId.trim() : ""; const loginMobile = normalizeSingleMobile(event.mobile); const code = typeof event.code === "string" ? event.code.trim() : ""; if (!corpId) { return { success: false, message: "corpId 不能为空" }; } if (!loginMobile) { return { success: false, message: "登录账号不能为空" }; } if (!code) { return { success: false, message: "验证码不能为空" }; } try { // 2. 查询 corp-member 表,获取 phone 字段 const { getDatabase } = require("../mongodb"); const corpDB = await getDatabase("corp"); const account = await corpDB.collection("corp-member").findOne( { corpId, mobile: loginMobile }, { projection: { phone: 1, _id: 1 } } ); if (!account) { return { success: false, message: "账号不存在" }; } const phone = typeof account.phone === "string" ? account.phone.trim() : ""; if (!phone) { return { success: false, message: "账号未绑定手机号,请联系管理员" }; } // 3. 验证码校验逻辑 const collection = await getVerificationCollection(); const doc = await collection.findOne({ mobile: phone }); if (!doc) { return { success: false, message: "验证码不存在或已过期", }; } const nowTs = Date.now(); if (doc.expireAt && doc.expireAt < nowTs) { return { success: false, message: "验证码已过期,请重新获取", }; } if (doc.verifiedAt) { return { success: false, message: "验证码已使用,请重新获取", }; } if (doc.corpId && corpId && corpId !== doc.corpId) { return { success: false, message: "验证码不适用于当前机构", }; } const attempts = doc.attempts || 0; if (attempts >= verification.maxAttempts) { return { success: false, message: "验证码尝试次数过多,请重新获取", }; } if (doc.code !== code) { await collection.updateOne( { _id: doc._id }, { $inc: { attempts: 1 }, $set: { updatedAt: nowTs }, } ); return { success: false, message: "验证码不正确", }; } const newExpireAt = nowTs + 60 * 1000; await collection.updateOne( { _id: doc._id }, { $set: { verifiedAt: nowTs, updatedAt: nowTs, expireAt: newExpireAt, expireAtDate: new Date(newExpireAt), }, $unset: { code: "", }, } ); return { success: true, message: "验证通过", }; } catch (error) { return { success: false, message: error.message || "验证失败", }; } } async function sendPlainSms(event) { const { ecName, apId, secretKey, sign, addSerial = "", mobiles, content, messageList, baseUrl, } = event; assertRequired(ecName, "ecName"); assertRequired(apId, "apId"); assertRequired(secretKey, "secretKey"); assertRequired(sign, "sign"); let payloadMobiles = ""; let payloadContent = ""; if (Array.isArray(messageList) && messageList.length > 0) { const contentMap = buildContentMap(messageList); payloadMobiles = ""; payloadContent = JSON.stringify(contentMap); } else if (isPlainObject(content)) { const contentMap = buildContentMapFromObject(content); payloadMobiles = ""; payloadContent = JSON.stringify(contentMap); } else { const normalizedMobiles = normalizeMobiles(mobiles); assertRequired(normalizedMobiles, "mobiles"); if (typeof content !== "string" || content.trim() === "") { throw new Error("content 必须为非空字符串"); } payloadMobiles = normalizedMobiles; payloadContent = content; } const payload = { ecName, apId, mobiles: payloadMobiles, content: payloadContent, sign, addSerial, }; payload.mac = client.buildMac([ payload.ecName, payload.apId, secretKey, payload.mobiles, payload.content, payload.sign, payload.addSerial, ]); const data = await client.post("/sms/submit", payload, { baseUrl }); return { success: true, message: "请求成功", data, }; } async function sendTemplateSms(event) { const { ecName, apId, secretKey, templateId, mobiles, params, sign, addSerial = "", baseUrl, } = event; assertRequired(ecName, "ecName"); assertRequired(apId, "apId"); assertRequired(secretKey, "secretKey"); assertRequired(templateId, "templateId"); assertRequired(sign, "sign"); const normalizedMobiles = normalizeMobiles(mobiles); assertRequired(normalizedMobiles, "mobiles"); const paramsString = normalizeParams(params); const payload = { ecName, apId, templateId, mobiles: normalizedMobiles, params: paramsString, sign, addSerial, }; payload.mac = client.buildMac([ payload.ecName, payload.apId, secretKey, payload.templateId, payload.mobiles, payload.params, payload.sign, payload.addSerial, ]); const data = await client.post("/sms/tmpsubmit", payload, { baseUrl }); return { success: true, message: "请求成功", data, }; } async function fetchReport(event) { const { ecName, apId, secretKey, baseUrl } = event; assertRequired(ecName, "ecName"); assertRequired(apId, "apId"); assertRequired(secretKey, "secretKey"); const payload = { ecName, apId, secretKey, }; const data = await client.post("/sms/report", payload, { baseUrl }); return { success: true, message: "请求成功", data, }; } async function fetchDeliver(event) { const { ecName, apId, secretKey, baseUrl } = event; assertRequired(ecName, "ecName"); assertRequired(apId, "apId"); assertRequired(secretKey, "secretKey"); const payload = { ecName, apId, secretKey, }; const data = await client.post("/sms/deliver", payload, { baseUrl }); return { success: true, message: "请求成功", data, }; } async function getVerificationCollection() { const db = await getDatabase("admin"); const collection = db.collection(COLLECTION_NAME); if (!ensureIndexesPromise) { ensureIndexesPromise = ensureVerificationIndexes(collection); } await ensureIndexesPromise; return collection; } async function ensureVerificationIndexes(collection) { try { // 检查已存在索引,避免因同名但 key 不同产生冲突 const existingIndexes = await collection.indexes(); const ttlIndex = existingIndexes.find((idx) => idx.name === "ttl_expireAt"); if (ttlIndex) { const hasExpireAtDate = ttlIndex.key && ttlIndex.key.expireAtDate; if (!hasExpireAtDate) { // 旧索引不是基于 expireAtDate,为避免冲突先删除再创建 try { await collection.dropIndex("ttl_expireAt"); } catch (err) { // 如果删除失败,清空 ensureIndexesPromise 并抛出错误 ensureIndexesPromise = null; throw err; } } } await collection.createIndexes([ { key: { mobile: 1 }, unique: true, name: "uniq_mobile", }, // expireAt 为时间戳(ms),仍然可以使用 TTL,但 MongoDB TTL 基于秒的 Date field. // 我们同时保留一个 Date 视图字段以便 TTL 生效(expireAtDate)。 { key: { expireAtDate: 1 }, expireAfterSeconds: 0, name: "ttl_expireAt", }, { key: { lastSentAt: 1 }, name: "idx_lastSentAt", }, ]); } catch (error) { ensureIndexesPromise = null; throw error; } } function generateVerificationCode(length) { const size = Number.isInteger(length) && length > 0 ? length : 6; let code = ""; for (let i = 0; i < size; i += 1) { code += crypto.randomInt(0, 10); } return code; } function hashVerificationCode(mobile, code) { const source = `${mobile}:${code}:${verification.codeSalt}`; return crypto.createHash("sha256").update(source, "utf8").digest("hex"); } function formatVerificationContent(code, template) { const expireMinutes = Math.ceil(verification.expireSeconds / 60) || 1; return template .replace(/\{code\}/g, code) .replace(/\{expire\}/g, String(expireMinutes)); } function calculateWaitSeconds(doc, nowTs) { // nowTs: numeric timestamp (ms) // 如果不存在记录或记录没有 lastSentAt,允许立即发送 if (!doc || !doc.lastSentAt) return 0; // doc.lastSentAt may be a Date or a number; normalize to ms const lastTs = doc.lastSentAt && typeof doc.lastSentAt.getTime === "function" ? doc.lastSentAt.getTime() : Number(doc.lastSentAt) || 0; const elapsed = Math.floor((nowTs - lastTs) / 1000); const remain = verification.resendIntervalSeconds - elapsed; return remain > 0 ? remain : 0; } function normalizeSingleMobile(value) { if (typeof value === "string") { return value.trim(); } if (Array.isArray(value) && value.length > 0) { return String(value[0]).trim(); } return ""; } function maskMobile(mobile) { if (!mobile || typeof mobile !== 'string') return ''; const s = mobile.trim(); // 常见手机号 11 位:前3 后4 掩码 if (s.length === 11) { return `${s.slice(0,3)}****${s.slice(7)}`; } // 较短或较长的手机号,根据长度中间打星 const len = s.length; if (len <= 4) return '*'.repeat(len); const prefixLen = Math.floor((len - 4) / 2); const suffixLen = len - 4 - prefixLen; return `${s.slice(0,prefixLen)}${'*'.repeat(4)}${s.slice(len - suffixLen)}`; } function isGatewaySuccess(response) { if (!response) return true; if (typeof response === "object") { if (response.success === false) return false; if (response.rspcod && response.rspcod !== "success") return false; } return true; } function getGatewayMessage(response) { if (!response || typeof response !== "object") return ""; return response.message || response.desc || response.rspcod || ""; } function sanitizeGatewayResponse(response) { if (!response || typeof response !== "object") { return response; } const { rspcod, msgGroup, success } = response; return { rspcod, msgGroup, success }; } async function rollbackVerificationDocument(collection, docId, previousDoc) { if (!docId) return; if (previousDoc) { await collection.replaceOne( { _id: previousDoc._id }, { ...previousDoc, updatedAt: Date.now(), } ); } else { await collection.deleteOne({ _id: docId }); } } function normalizeMobiles(value) { if (value === null || value === undefined) return ""; if (Array.isArray(value)) { return value.filter((item) => !!item).join(","); } if (typeof value === "string") { return value.trim(); } throw new Error("mobiles 格式不正确,应为字符串或数组"); } function normalizeParams(value) { if (value === undefined || value === null) { return '[""]'; } if (typeof value === "string") { return value; } if (Array.isArray(value)) { return JSON.stringify(value); } throw new Error("params 格式不正确,应为字符串或数组"); } function buildContentMap(list) { return list.reduce((acc, item, index) => { if (!item || typeof item !== "object") { throw new Error(`messageList 第 ${index + 1} 项格式错误`); } const mobile = typeof item.mobile === "string" ? item.mobile.trim() : ""; const text = typeof item.content === "string" ? item.content : ""; if (!mobile) { throw new Error(`messageList 第 ${index + 1} 项缺少 mobile`); } if (!text) { throw new Error(`messageList 第 ${index + 1} 项缺少 content`); } acc[mobile] = text; return acc; }, {}); } function buildContentMapFromObject(value) { return Object.entries(value).reduce((acc, [mobile, text]) => { if (typeof text !== "string" || !text) { throw new Error(`content.${mobile} 必须为非空字符串`); } acc[mobile] = text; return acc; }, {}); } function assertRequired(value, field) { if (value === undefined || value === null || value === "") { throw new Error(`${field} 不能为空`); } } function isPlainObject(value) { return Object.prototype.toString.call(value) === "[object Object]"; }