714 lines
19 KiB
JavaScript
Raw Permalink Normal View History

2026-07-27 11:28:33 +08:00
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]";
}