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