You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
6.1 KiB
182 lines
6.1 KiB
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const uuid = require('uuid');
|
|
const svgCaptcha = require('svg-captcha');
|
|
|
|
const PHONE_REGEXP = /^1\d{10}$/;
|
|
const VERIFY_CODE_EXPIRE_MINUTES = 5;
|
|
const VERIFY_CODE_SEND_INTERVAL_SECONDS = 60;
|
|
const VERIFY_CODE_EXPIRE_SECONDS = VERIFY_CODE_EXPIRE_MINUTES * 60;
|
|
const CAPTCHA_EXPIRE_SECONDS = 5 * 60;
|
|
const SEND_CODE_RISK_WINDOW_SECONDS = 3 * 60;
|
|
const SEND_CODE_CAPTCHA_THRESHOLD = 3;
|
|
const SMS_BUSINESS_TYPE = {
|
|
LOGIN: 'LOGIN',
|
|
REGISTER: 'REGISTER',
|
|
};
|
|
const REDIS_TENDER_SMS_CODE_KEY_PREFIX = 'tender:user:sms:code:';
|
|
const REDIS_TENDER_SMS_LOCK_KEY_PREFIX = 'tender:user:sms:lock:';
|
|
const REDIS_TENDER_SMS_CAPTCHA_KEY_PREFIX = 'tender:user:sms:captcha:';
|
|
const REDIS_TENDER_SMS_SEND_COUNT_KEY_PREFIX = 'tender:user:sms:send-count:';
|
|
|
|
const getRedis = (ctx) => ctx?.app?.fs?.redis;
|
|
const createSmsVerifyCode = () => String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
const normalizeBusinessType = (value) => {
|
|
const raw = String(value || '').trim().toUpperCase();
|
|
if (raw === SMS_BUSINESS_TYPE.LOGIN) return SMS_BUSINESS_TYPE.LOGIN;
|
|
if (raw === SMS_BUSINESS_TYPE.REGISTER) return SMS_BUSINESS_TYPE.REGISTER;
|
|
throw 'businessType仅支持 LOGIN 或 REGISTER';
|
|
};
|
|
|
|
const buildCaptchaPayload = async (ctx) => {
|
|
const redis = getRedis(ctx);
|
|
if (!redis) {
|
|
throw 'Redis未配置,无法生成图形验证码';
|
|
}
|
|
const captchaId = uuid.v4();
|
|
const captcha = svgCaptcha.create({
|
|
size: 4,
|
|
noise: 4,
|
|
color: true,
|
|
ignoreChars: '0oO1ilI',
|
|
width: 120,
|
|
height: 42,
|
|
});
|
|
const captchaText = String(captcha?.text || '').trim();
|
|
const captchaSvg = String(captcha?.data || '').trim();
|
|
if (!captchaText || !captchaSvg) {
|
|
throw '生成图形验证码失败';
|
|
}
|
|
const captchaKey = `${REDIS_TENDER_SMS_CAPTCHA_KEY_PREFIX}${captchaId}`;
|
|
await redis.set(captchaKey, String(captchaText).toLowerCase(), 'EX', CAPTCHA_EXPIRE_SECONDS);
|
|
const captchaImage = `data:image/svg+xml;base64,${Buffer.from(captchaSvg).toString('base64')}`;
|
|
return {
|
|
captchaId,
|
|
captchaImage,
|
|
expireInSeconds: CAPTCHA_EXPIRE_SECONDS,
|
|
};
|
|
};
|
|
|
|
const sendCode = async (ctx, params = {}) => {
|
|
const { phone, captchaId, captchaCode, businessType } = params;
|
|
const normalizedBusinessType = normalizeBusinessType(businessType);
|
|
if (!phone || !PHONE_REGEXP.test(String(phone))) {
|
|
throw '请输入正确的手机号';
|
|
}
|
|
|
|
const redis = getRedis(ctx);
|
|
if (!redis) {
|
|
throw 'Redis未配置,无法发送验证码';
|
|
}
|
|
|
|
const sendCountKey = `${REDIS_TENDER_SMS_SEND_COUNT_KEY_PREFIX}${phone}:${normalizedBusinessType}`;
|
|
const requestCount = await redis.incr(sendCountKey);
|
|
if (requestCount === 1) {
|
|
await redis.expire(sendCountKey, SEND_CODE_RISK_WINDOW_SECONDS);
|
|
}
|
|
const requireCaptcha = requestCount > SEND_CODE_CAPTCHA_THRESHOLD;
|
|
if (requireCaptcha) {
|
|
const normalizedCaptchaId = String(captchaId || '').trim();
|
|
const normalizedCaptchaCode = String(captchaCode || '').trim().toLowerCase();
|
|
const captchaKey = normalizedCaptchaId
|
|
? `${REDIS_TENDER_SMS_CAPTCHA_KEY_PREFIX}${normalizedCaptchaId}`
|
|
: '';
|
|
const cachedCaptchaCode = captchaKey ? await redis.get(captchaKey) : null;
|
|
|
|
if (!normalizedCaptchaId || !normalizedCaptchaCode) {
|
|
const captchaPayload = await buildCaptchaPayload(ctx);
|
|
return {
|
|
message: '请先输入图形验证码',
|
|
needCaptcha: true,
|
|
...captchaPayload,
|
|
};
|
|
}
|
|
if (!cachedCaptchaCode) {
|
|
if (captchaKey) {
|
|
await redis.del(captchaKey);
|
|
}
|
|
const captchaPayload = await buildCaptchaPayload(ctx);
|
|
return {
|
|
message: '图形验证码已过期,请重新输入',
|
|
needCaptcha: true,
|
|
...captchaPayload,
|
|
};
|
|
}
|
|
if (cachedCaptchaCode !== normalizedCaptchaCode) {
|
|
await redis.del(captchaKey);
|
|
const captchaPayload = await buildCaptchaPayload(ctx);
|
|
return {
|
|
message: '图形验证码错误,请重新输入',
|
|
needCaptcha: true,
|
|
...captchaPayload,
|
|
};
|
|
}
|
|
await redis.del(captchaKey);
|
|
}
|
|
|
|
const smsLockKey = `${REDIS_TENDER_SMS_LOCK_KEY_PREFIX}${phone}:${normalizedBusinessType}`;
|
|
const lockResult = await redis.set(
|
|
smsLockKey,
|
|
'1',
|
|
'EX',
|
|
VERIFY_CODE_SEND_INTERVAL_SECONDS,
|
|
'NX',
|
|
);
|
|
if (!lockResult) {
|
|
throw '发送过于频繁,请稍后再试';
|
|
}
|
|
|
|
const code = createSmsVerifyCode();
|
|
const smsCodeKey = `${REDIS_TENDER_SMS_CODE_KEY_PREFIX}${phone}:${normalizedBusinessType}`;
|
|
try {
|
|
const smsConf = ctx.app?.fs?.config?.tenderUser?.sms || {};
|
|
const smsAli = ctx.app?.fs?.smsAli;
|
|
if (typeof smsAli !== 'function') {
|
|
throw '短信服务未启用';
|
|
}
|
|
const signName = String(smsConf.signName || '').trim();
|
|
const templateCode = String(smsConf.templateCode || '').trim();
|
|
const templateParamKey = String(smsConf.templateParamKey || 'code').trim() || 'code';
|
|
if (!signName || !templateCode) {
|
|
throw '短信配置不完整,请联系管理员';
|
|
}
|
|
|
|
const smsRes = await smsAli({
|
|
phone: [String(phone)],
|
|
SignName: signName,
|
|
templateCode,
|
|
templateParam: {
|
|
[templateParamKey]: code,
|
|
},
|
|
});
|
|
if (!smsRes || smsRes.Code !== 'OK') {
|
|
throw (smsRes?.Message || '短信发送失败');
|
|
}
|
|
|
|
await redis.setex(
|
|
smsCodeKey,
|
|
VERIFY_CODE_EXPIRE_SECONDS,
|
|
JSON.stringify({ code, phone: String(phone), businessType: normalizedBusinessType }),
|
|
);
|
|
} catch (error) {
|
|
await redis.del(smsCodeKey);
|
|
await redis.del(smsLockKey);
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
phone: String(phone),
|
|
businessType: normalizedBusinessType,
|
|
expireInMinutes: VERIFY_CODE_EXPIRE_MINUTES,
|
|
};
|
|
};
|
|
|
|
module.exports = async (app) => {
|
|
app.fs = app.fs || {};
|
|
app.fs.tenderUserService = {
|
|
buildCaptchaPayload,
|
|
sendCode,
|
|
normalizeBusinessType,
|
|
};
|
|
};
|
|
|