2026-07-27 11:28:33 +08:00

714 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]";
}