714 lines
19 KiB
JavaScript
714 lines
19 KiB
JavaScript
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]";
|
||
}
|