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.
 
 
 

580 lines
18 KiB

'use strict';
const crypto = require('crypto');
const uuid = require('uuid');
const PHONE_REGEXP = /^1\d{10}$/;
const CODE_REGEXP = /^\d{6}$/;
const LOGIN_TOKEN_EXPIRE_SECONDS = 3 * 24 * 60 * 60;
const SMS_BUSINESS_TYPE = {
LOGIN: 'LOGIN',
REGISTER: 'REGISTER',
};
const REDIS_TENDER_TOKEN_KEY_PREFIX = 'tender:user:token:';
const getModels = (ctx) => ctx?.fs?.models || ctx?.fs?.dc?.models || {};
const getRedis = (ctx) => ctx?.app?.fs?.redis;
const getTenderUserService = (ctx) => ctx?.app?.fs?.tenderUserService;
const getAideductService = (ctx) => ctx?.app?.fs?.aideductService;
const getQuotaService = (ctx) => ctx?.app?.fs?.quotaBillingService;
const getCenterRequest = (ctx) => ctx?.app?.fs?.centerRequest;
const getRequestToken = (ctx) => {
const { query = {}, header = {} } = ctx;
const authHeader = header.authorization || header.Authorization;
if (query.token) return query.token;
if (header.token) return header.token;
if (authHeader && /^Bearer\s+/i.test(authHeader)) {
return authHeader.replace(/^Bearer\s+/i, '').trim();
}
return '';
};
const md5 = (text = '') => crypto.createHash('md5').update(String(text)).digest('hex');
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 normalizeNullableDepartmentId = (value) => {
if (value === undefined || value === null || value === '') return null;
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
};
const buildLoginPayload = (user) => ({
departmentId: normalizeNullableDepartmentId(user.departmentId),
department: normalizeNullableDepartmentId(user.departmentId)
? [{ id: normalizeNullableDepartmentId(user.departmentId) }]
: [],
id: user.id,
pepId: user.pepId,
pepUserId: user.pepId,
status: user.status,
displayName: user.displayName,
realName: user.realName,
phone: user.phone,
email: user.email,
avatarUrl: user.avatarUrl,
registerSource: user.registerSource,
lastLoginAt: user.lastLoginAt,
needSetPassword: (user.registerSource === 'PHONE') && !Boolean(user.password),
});
const issueLoginToken = async (ctx, user, issuedToken) => {
const redis = getRedis(ctx);
if (!redis) {
throw 'Redis未配置,无法完成登录鉴权';
}
const token = issuedToken || uuid.v4();
const tokenValue = {
id: user.id,
userType: 'TENDER_USER',
};
const tokenKey = `${REDIS_TENDER_TOKEN_KEY_PREFIX}${token}`;
await redis.set(tokenKey, JSON.stringify(tokenValue), 'EX', LOGIN_TOKEN_EXPIRE_SECONDS);
const userInfo = {
...buildLoginPayload(user),
authorized: true,
userType: 'TENDER_USER',
token,
};
return userInfo;
};
const resolveAiCenterToken = (res = {}) => String(
res?.token
|| res?.data?.token
|| res?.body?.token
|| res?.body?.data?.token
|| res?.userInfo?.token
|| res?.data?.userInfo?.token
|| '',
).trim();
const resolveAiCenterUser = (res = {}) => (
res?.AIUserInfo
|| res?.data?.AIUserInfo
|| res?.data?.userInfo
|| res?.body?.userInfo
|| res?.body?.data?.userInfo
|| res?.data
|| res?.body?.data
|| res?.body
|| {}
);
const getAiCenterAuthConfig = (ctx) => ({
passwordLoginPath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.passwordLoginPath || '/api/support/user/account/loginByPassword',
loginByCodePath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.loginByCodePath || '/api/support/user/account/loginByCode',
registerByCodePath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.registerByCodePath || '/api/support/user/account/registerByCode',
sendCodePath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.sendCodePath || '/send-code',
phoneRegisteredPath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.phoneRegisteredPath || '/phone-registered',
profilePath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.profilePath || '/profile',
logoutPath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.logoutPath || '/logout',
verifyTokenPath: ctx?.app?.fs?.config?.tenderUser?.aiCenter?.verifyTokenPath || '/verify-token',
});
const normalizeAiCenterAuthPayload = (res = {}) => {
const AIUserInfo = resolveAiCenterUser(res);
const token = resolveAiCenterToken(res);
const authorizedRaw = res?.authorized ?? res?.data?.authorized;
return {
AIUserInfo,
token,
authorized: authorizedRaw === undefined ? Boolean(token) : Boolean(authorizedRaw),
};
};
const callAiCenterAuth = async (ctx, path, payload) => {
const centerRequest = getCenterRequest(ctx);
if (!centerRequest) {
throw 'ai-center登录服务未初始化';
}
const res = await centerRequest.post(path, { body: payload });
if (!res) {
throw '无此用户,请使用正确的登录信息';
}
const status = Number(res?.code ?? res?.status ?? 200);
if (status >= 400) {
throw res?.message || res?.msg || 'ai-center登录失败';
}
return res;
};
const upsertTenderUserByAiCenterUser = async (ctx, centerUser = {}, fallbackPhone = null) => {
const models = getModels(ctx);
if (!models.TenderUser) {
throw '登录服务未初始化';
}
const centerUserId = String(
centerUser?.pepUserId
|| centerUser?.pep_user_id
|| centerUser?.id
|| centerUser?.userId
|| centerUser?.uid
|| '',
).trim();
const phone = String(centerUser?.phone || centerUser?.mobile || fallbackPhone || '').trim() || null;
if (!centerUserId && !phone) {
throw 'ai-center用户信息异常,缺少用户标识';
}
let user = null;
if (centerUserId) {
user = await models.TenderUser.findOne({
where: {
pepId: centerUserId,
deletedAt: null,
},
});
}
if (!user && phone) {
user = await models.TenderUser.findOne({
where: {
phone,
deletedAt: null,
},
});
}
const nextValues = {
pepId: centerUserId || null,
phone,
displayName: centerUser?.displayName || centerUser?.name || centerUser?.username || (phone ? `用户${phone.slice(-4)}` : `用户${centerUserId.slice(-4)}`),
realName: centerUser?.realName || centerUser?.name || null,
email: centerUser?.email || null,
status: 1,
registerSource: phone ? 'PHONE' : 'PEP',
lastLoginAt: new Date(),
};
const departmentId = normalizeNullableDepartmentId(
centerUser?.departmentId ?? centerUser?.deptId ?? centerUser?.department?.id,
);
if (departmentId !== null) {
nextValues.departmentId = departmentId;
}
if (!user) {
user = await models.TenderUser.create(nextValues);
} else {
if (Number(user.status) !== 1) {
throw '账号已被禁用';
}
await user.update(nextValues);
}
return user;
};
const refreshCurrentTokenPayload = async (ctx, user) => {
const redis = getRedis(ctx);
const { header, query } = ctx;
const token = query.token || header.token;
if (!redis || !token) return;
const tokenKey = `${REDIS_TENDER_TOKEN_KEY_PREFIX}${token}`;
const nextTokenValue = {
id: user.id,
userType: 'TENDER_USER',
};
const ttl = await redis.ttl(tokenKey);
await redis.set(
tokenKey,
JSON.stringify(nextTokenValue),
'EX',
ttl > 0 ? ttl : LOGIN_TOKEN_EXPIRE_SECONDS,
);
};
module.exports.captcha = async (ctx) => {
try {
const service = getTenderUserService(ctx);
if (!service?.buildCaptchaPayload) {
throw '验证码服务未初始化';
}
const payload = await service.buildCaptchaPayload(ctx);
ctx.status = 200;
ctx.body = payload;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : '获取图形验证码失败',
};
}
};
module.exports.sendCode = async (ctx) => {
try {
const { phone, businessType } = ctx.request.body || {};
if (!phone || !PHONE_REGEXP.test(String(phone))) {
throw '请输入正确的手机号';
}
const normalizedBusinessType = normalizeBusinessType(businessType);
const normalizedPhone = String(phone);
// 注册状态必须以 ai-center 用户中心为准,避免本地 TenderUser 同步滞后导致误判。
const { phoneRegisteredPath } = getAiCenterAuthConfig(ctx);
const registeredResult = await callAiCenterAuth(ctx, phoneRegisteredPath, {
phone: normalizedPhone,
});
const isRegistered = Boolean(registeredResult?.registered ?? registeredResult?.data?.registered);
const isLogin = normalizedBusinessType === SMS_BUSINESS_TYPE.LOGIN;
if (isLogin && !isRegistered) {
throw '该手机号未注册,请先注册';
}
if (!isLogin && isRegistered) {
throw '该手机号已注册,请直接登录';
}
const { sendCodePath } = getAiCenterAuthConfig(ctx);
const result = await callAiCenterAuth(ctx, sendCodePath, {
phone: normalizedPhone,
businessType: normalizedBusinessType,
});
if (result?.success === false) {
throw result?.message || '发送验证码失败';
}
ctx.status = 200;
ctx.body = result;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : (error?.response?.body?.message || error?.message || '发送验证码失败'),
};
}
};
module.exports.loginOrRegisterByCode = async (ctx) => {
try {
const { phone, code, businessType, name, password } = ctx.request.body || {};
const normalizedBusinessType = normalizeBusinessType(businessType);
if (!phone || !PHONE_REGEXP.test(String(phone))) {
throw '请输入正确的手机号';
}
if (!code || !CODE_REGEXP.test(String(code))) {
throw '请输入正确验证码';
}
const { loginByCodePath, registerByCodePath } = getAiCenterAuthConfig(ctx);
const path = normalizedBusinessType === SMS_BUSINESS_TYPE.REGISTER
? registerByCodePath
: loginByCodePath;
const aiCenterRes = await callAiCenterAuth(ctx, path, {
phone: String(phone),
code: String(code),
businessType: normalizedBusinessType,
name: String(name || '').trim(),
password: String(password || ''),
});
const userInfo = normalizeAiCenterAuthPayload(aiCenterRes);
ctx.status = 200;
ctx.body = userInfo;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string'
? error
: (error?.response?.body?.message || error?.message || '验证码登录失败'),
};
}
};
module.exports.loginByPassword = async (ctx) => {
try {
const { username, password } = ctx.request.body || {};
if (!username || !String(username).trim()) {
throw '请输入账号';
}
if (!password || !String(password).trim()) {
throw '请输入密码';
}
const loginName = String(username).trim();
const { passwordLoginPath } = getAiCenterAuthConfig(ctx);
const aiCenterRes = await callAiCenterAuth(ctx, passwordLoginPath, {
username: loginName,
password: String(password),
});
const userInfo = normalizeAiCenterAuthPayload(aiCenterRes);
ctx.status = 200;
ctx.body = userInfo;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : (error?.response?.body?.message || error?.message || '账号登录失败'),
};
}
};
module.exports.setPassword = async (ctx) => {
try {
const { password } = ctx.request.body || {};
if (!password || String(password).trim().length < 6) {
throw '密码长度不能少于6位';
}
const models = getModels(ctx);
const curUser = ctx?.fs?.curUser?.userInfo;
if (!curUser?.id) {
throw '用户未登录';
}
const user = await models.TenderUser.findOne({
where: {
id: curUser.id,
deletedAt: null,
},
});
if (!user) {
throw '用户不存在';
}
if (user.registerSource === 'PEP') {
throw '项企账号不支持修改密码';
}
await user.update({
password: md5(String(password)),
registerSource: user.registerSource === 'PEP' ? 'PEP' : 'PHONE',
});
await refreshCurrentTokenPayload(ctx, user);
ctx.status = 200;
ctx.body = {
...buildLoginPayload(user),
needSetPassword: false,
};
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : (error?.message || '设置密码失败'),
};
}
};
module.exports.updateProfile = async (ctx) => {
try {
const { avatarUrl = '', displayName = '', phone } = ctx.request.body || {};
const models = getModels(ctx);
const curUser = ctx?.fs?.curUser?.userInfo;
if (!curUser?.id) {
throw '用户未登录';
}
const user = await models.TenderUser.findOne({
where: {
id: curUser.id,
deletedAt: null,
},
});
if (!user) {
throw '用户不存在';
}
if (user.registerSource === 'PEP') {
throw '项企账号不支持修改头像或昵称';
}
const nextAvatarUrl = String(avatarUrl || '').trim();
const nextDisplayName = String(displayName || '').trim();
const currentPhone = String(user.phone || '').trim();
const nextPhone = phone === undefined ? undefined : String(phone || '').trim();
if (nextPhone !== undefined) {
if (nextPhone && !PHONE_REGEXP.test(nextPhone)) {
throw '请输入正确的11位手机号';
}
if (currentPhone && nextPhone !== currentPhone) {
throw '手机号已绑定,不支持修改';
}
if (!currentPhone && nextPhone) {
const duplicatedUser = await models.TenderUser.findOne({
where: {
phone: nextPhone,
deletedAt: null,
},
});
if (duplicatedUser && Number(duplicatedUser.id) !== Number(user.id)) {
throw '该手机号已被其他账号占用';
}
}
}
const nextValues = {
avatarUrl: nextAvatarUrl || null,
displayName: nextDisplayName || user.displayName,
};
if (!currentPhone && nextPhone) {
nextValues.phone = nextPhone;
}
await user.update({
...nextValues,
});
await refreshCurrentTokenPayload(ctx, user);
ctx.status = 200;
ctx.body = buildLoginPayload(user);
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : (error?.message || '修改个人信息失败'),
};
}
};
module.exports.logout = async (ctx) => {
try {
const token = getRequestToken(ctx);
if (!token) {
throw '缺少token';
}
const centerRequest = getCenterRequest(ctx);
if (!centerRequest) {
throw 'ai-center登录服务未初始化';
}
const { logoutPath } = getAiCenterAuthConfig(ctx);
await centerRequest.put(logoutPath, {
query: { token },
});
ctx.status = 204;
ctx.body = {};
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : '登出失败',
};
}
};
module.exports.officialProfile = async (ctx) => {
try {
const from = String(ctx?.request?.query?.from || '').trim();
const externalUserId = String(ctx?.request?.query?.userid || '').trim();
if (from !== 'OfficialWebsite') {
throw '仅支持官网入口调用';
}
if (!externalUserId) {
throw '缺少userid';
}
// 调用 ai-center 的官网用户认证接口
const centerRequest = getCenterRequest(ctx);
if (!centerRequest) {
throw 'ai-center服务未初始化';
}
const aiCenterRes = await centerRequest.get('/official-profile', {
query: {
from: 'OfficialWebsite',
userid: externalUserId,
},
});
if (!aiCenterRes) {
throw '官网用户认证失败';
}
const status = Number(aiCenterRes?.code ?? aiCenterRes?.status ?? 200);
if (status >= 400) {
throw aiCenterRes?.message || aiCenterRes?.msg || '官网用户认证失败';
}
ctx.status = 200;
ctx.body = {
registered: true,
...aiCenterRes,
};
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error === 'string' ? error : (error?.message || '官网用户信息查询失败'),
};
}
};
module.exports.profile = async (ctx) => {
try {
const token = getRequestToken(ctx);
if (!token) {
throw '用户未登录';
}
const { profilePath } = getAiCenterAuthConfig(ctx);
const centerRequest = getCenterRequest(ctx);
if (!centerRequest) {
throw 'ai-center用户服务未初始化';
}
const centerRes = await centerRequest.get(profilePath, {
query: { token },
});
if (!centerRes) {
throw '获取用户信息失败';
}
ctx.status = 200;
ctx.body = {
AIUserInfo: resolveAiCenterUser(centerRes),
};
} catch (error) {
ctx.logger.log(error);
ctx.status = error?.status || error?.response?.status || 400;
ctx.body = {
message: typeof error === 'string'
? error
: (error?.response?.body?.message || error?.message || '获取用户信息失败'),
};
}
};