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