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
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 || '获取用户信息失败'),
|
|
};
|
|
}
|
|
};
|
|
|