ai-query对接新版freesun-agent接口的分支
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

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