'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, }; };