|
|
import { TcCacheCleaner } from '../../../mixins/cache.cleaner.mixin';
|
|
|
import jwt from 'jsonwebtoken';
|
|
|
import bcrypt from 'bcryptjs';
|
|
|
import type {
|
|
|
User,
|
|
|
UserDocument,
|
|
|
UserLoginRes,
|
|
|
UserModel,
|
|
|
} from '../../../models/user/user';
|
|
|
import {
|
|
|
TcService,
|
|
|
TcDbService,
|
|
|
TcContext,
|
|
|
TcPureContext,
|
|
|
UserJWTPayload,
|
|
|
config,
|
|
|
PureContext,
|
|
|
Errors,
|
|
|
DataNotFoundError,
|
|
|
EntityError,
|
|
|
db,
|
|
|
call,
|
|
|
NoPermissionError,
|
|
|
} from 'tailchat-server-sdk';
|
|
|
import {
|
|
|
generateRandomNumStr,
|
|
|
generateRandomStr,
|
|
|
getEmailAddress,
|
|
|
} from '../../../lib/utils';
|
|
|
import type { TFunction } from 'i18next';
|
|
|
import _ from 'lodash';
|
|
|
|
|
|
const { isValidObjectId, Types } = db;
|
|
|
|
|
|
/**
|
|
|
* 用户服务
|
|
|
*/
|
|
|
interface UserService extends TcService, TcDbService<UserDocument, UserModel> {}
|
|
|
class UserService extends TcService {
|
|
|
get serviceName() {
|
|
|
return 'user';
|
|
|
}
|
|
|
|
|
|
onInit() {
|
|
|
this.registerLocalDb(require('../../../models/user/user').default);
|
|
|
this.registerMixin(TcCacheCleaner(['cache.clean.user']));
|
|
|
|
|
|
// Public fields
|
|
|
this.registerDbField([
|
|
|
'_id',
|
|
|
'username',
|
|
|
'email',
|
|
|
'nickname',
|
|
|
'discriminator',
|
|
|
'temporary',
|
|
|
'avatar',
|
|
|
'type',
|
|
|
'emailVerified',
|
|
|
'extra',
|
|
|
'createdAt',
|
|
|
]);
|
|
|
|
|
|
this.registerAction('login', this.login, {
|
|
|
rest: 'POST /login',
|
|
|
params: {
|
|
|
username: [{ type: 'string', optional: true }],
|
|
|
email: [{ type: 'email', optional: true }],
|
|
|
password: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('verifyEmail', this.verifyEmail, {
|
|
|
params: {
|
|
|
email: 'email',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('verifyEmailWithOTP', this.verifyEmailWithOTP, {
|
|
|
params: {
|
|
|
emailOTP: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('register', this.register, {
|
|
|
rest: 'POST /register',
|
|
|
params: {
|
|
|
username: { type: 'string', optional: true, max: 40 },
|
|
|
email: { type: 'email', optional: true, max: 40 },
|
|
|
password: { type: 'string', max: 40 },
|
|
|
emailOTP: { type: 'string', optional: true },
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('modifyPassword', this.modifyPassword, {
|
|
|
rest: 'POST /modifyPassword',
|
|
|
params: {
|
|
|
oldPassword: 'string',
|
|
|
newPassword: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('createTemporaryUser', this.createTemporaryUser, {
|
|
|
params: {
|
|
|
nickname: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('claimTemporaryUser', this.claimTemporaryUser, {
|
|
|
params: {
|
|
|
userId: 'string',
|
|
|
username: { type: 'string', optional: true, max: 40 },
|
|
|
email: { type: 'email', max: 40 },
|
|
|
password: { type: 'string', max: 40 },
|
|
|
emailOTP: { type: 'string', optional: true },
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('forgetPassword', this.forgetPassword, {
|
|
|
rest: {
|
|
|
method: 'POST',
|
|
|
},
|
|
|
params: {
|
|
|
email: 'email',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('resetPassword', this.resetPassword, {
|
|
|
rest: {
|
|
|
method: 'POST',
|
|
|
},
|
|
|
params: {
|
|
|
email: 'email',
|
|
|
password: 'string',
|
|
|
otp: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('resolveToken', this.resolveToken, {
|
|
|
cache: {
|
|
|
keys: ['token'],
|
|
|
ttl: 60 * 60, // 1 hour
|
|
|
},
|
|
|
params: {
|
|
|
token: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('checkTokenValid', this.checkTokenValid, {
|
|
|
cache: {
|
|
|
keys: ['token'],
|
|
|
ttl: 60 * 60, // 1 hour
|
|
|
},
|
|
|
params: {
|
|
|
token: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('whoami', this.whoami);
|
|
|
this.registerAction(
|
|
|
'searchUserWithUniqueName',
|
|
|
this.searchUserWithUniqueName,
|
|
|
{
|
|
|
params: {
|
|
|
uniqueName: 'string',
|
|
|
},
|
|
|
}
|
|
|
);
|
|
|
this.registerAction('getUserInfo', this.getUserInfo, {
|
|
|
params: {
|
|
|
userId: 'string',
|
|
|
},
|
|
|
cache: {
|
|
|
keys: ['userId'],
|
|
|
ttl: 60 * 60, // 1 hour
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('getUserInfoList', this.getUserInfoList, {
|
|
|
params: {
|
|
|
userIds: {
|
|
|
type: 'array',
|
|
|
items: 'string',
|
|
|
},
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('updateUserField', this.updateUserField, {
|
|
|
params: {
|
|
|
fieldName: 'string',
|
|
|
fieldValue: 'any',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('updateUserExtra', this.updateUserExtra, {
|
|
|
params: {
|
|
|
fieldName: 'string',
|
|
|
fieldValue: 'any',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('getUserSettings', this.getUserSettings);
|
|
|
this.registerAction('setUserSettings', this.setUserSettings, {
|
|
|
params: {
|
|
|
settings: 'object',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('ensurePluginBot', this.ensurePluginBot, {
|
|
|
params: {
|
|
|
/**
|
|
|
* 用户名唯一id, 创建的用户邮箱会为 <botId>@tailchat-plugin.com
|
|
|
*/
|
|
|
botId: 'string',
|
|
|
nickname: 'string',
|
|
|
avatar: { type: 'string', optional: true },
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('findOpenapiBotId', this.findOpenapiBotId, {
|
|
|
params: {
|
|
|
email: 'string',
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('ensureOpenapiBot', this.ensureOpenapiBot, {
|
|
|
params: {
|
|
|
/**
|
|
|
* 用户名唯一id, 创建的用户邮箱会为 <botId>@tailchat-open.com
|
|
|
*/
|
|
|
botId: 'string',
|
|
|
nickname: 'string',
|
|
|
avatar: { type: 'string', optional: true },
|
|
|
},
|
|
|
});
|
|
|
this.registerAction('generateUserToken', this.generateUserToken, {
|
|
|
params: {
|
|
|
userId: 'string',
|
|
|
nickname: 'string',
|
|
|
email: 'string',
|
|
|
avatar: 'string',
|
|
|
},
|
|
|
visibility: 'public',
|
|
|
});
|
|
|
|
|
|
this.registerAuthWhitelist([
|
|
|
'/verifyEmail',
|
|
|
'/forgetPassword',
|
|
|
'/resetPassword',
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* jwt秘钥
|
|
|
*/
|
|
|
get jwtSecretKey() {
|
|
|
return config.secret;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 生成hash密码
|
|
|
*/
|
|
|
hashPassword = async (password: string): Promise<string> =>
|
|
|
bcrypt.hash(password, 10);
|
|
|
/**
|
|
|
* 对比hash密码是否正确
|
|
|
*/
|
|
|
comparePassword = async (password: string, hash: string): Promise<boolean> =>
|
|
|
bcrypt.compare(password, hash);
|
|
|
|
|
|
/**
|
|
|
* 用户登录
|
|
|
* 登录可以使用用户名登录或者邮箱登录
|
|
|
*/
|
|
|
async login(
|
|
|
ctx: PureContext<
|
|
|
{ username?: string; email?: string; password: string },
|
|
|
any
|
|
|
>
|
|
|
): Promise<UserLoginRes> {
|
|
|
const { username, email, password } = ctx.params;
|
|
|
const { t } = ctx.meta;
|
|
|
|
|
|
let user: UserDocument;
|
|
|
if (typeof username === 'string') {
|
|
|
user = await this.adapter.findOne({ username });
|
|
|
if (!user) {
|
|
|
throw new EntityError(t('用户不存在, 请检查您的用户名'), 442, '', [
|
|
|
{ field: 'username', message: t('用户名不存在') },
|
|
|
]);
|
|
|
}
|
|
|
} else if (typeof email === 'string') {
|
|
|
user = await this.adapter.findOne({ email });
|
|
|
if (!user) {
|
|
|
throw new EntityError(t('用户不存在, 请检查您的邮箱'), 422, '', [
|
|
|
{ field: 'email', message: t('邮箱不存在') },
|
|
|
]);
|
|
|
}
|
|
|
} else {
|
|
|
throw new EntityError(t('用户名或邮箱为空'), 422, '', [
|
|
|
{ field: 'email', message: t('邮箱不存在') },
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
const res = await this.comparePassword(password, user.password);
|
|
|
if (!res) {
|
|
|
throw new EntityError(t('密码错误'), 422, '', [
|
|
|
{ field: 'password', message: t('密码错误') },
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
if (user.banned === true) {
|
|
|
throw new NoPermissionError(t('用户被封禁'), 403);
|
|
|
}
|
|
|
|
|
|
// Transform user entity (remove password and all protected fields)
|
|
|
const doc = await this.transformDocuments(ctx, {}, user);
|
|
|
return await this.transformEntity(doc, true, ctx.meta.token);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 验证用户邮箱, 会往邮箱发送一个 OTP 作为唯一标识
|
|
|
* 需要在注册的时候带上
|
|
|
*/
|
|
|
async verifyEmail(ctx: TcPureContext<{ email: string }>) {
|
|
|
const email = ctx.params.email;
|
|
|
const t = ctx.meta.t;
|
|
|
const cacheKey = this.buildVerifyEmailKey(email);
|
|
|
|
|
|
const c = await this.broker.cacher.get(cacheKey);
|
|
|
if (!!c) {
|
|
|
// 如果有一个忘记密码请求未到期
|
|
|
throw new Error(t('过于频繁的请求,10 分钟内可以共用同一OTP'));
|
|
|
}
|
|
|
|
|
|
const otp = generateRandomNumStr(6); // 产生一次性6位数字密码
|
|
|
|
|
|
const html = `
|
|
|
<p>您正在尝试验证 Tailchat 账号的邮箱, 请使用以下 OTP 作为邮箱验证凭证:</p>
|
|
|
<h3>OTP: <strong>${otp}</strong></h3>
|
|
|
<p>该 OTP 将会在 10分钟 后过期</p>
|
|
|
<p style="color: grey;">如果并不是您触发的验证操作,请忽略此电子邮件。</p>`;
|
|
|
|
|
|
await ctx.call('mail.sendMail', {
|
|
|
to: email,
|
|
|
subject: `Tailchat 邮箱验证: ${otp}`,
|
|
|
html,
|
|
|
});
|
|
|
|
|
|
await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 通过用户邮件验证OTP, 并更新用户验证状态
|
|
|
*/
|
|
|
async verifyEmailWithOTP(ctx: TcContext<{ emailOTP: string }>) {
|
|
|
const emailOTP = ctx.params.emailOTP;
|
|
|
const userId = ctx.meta.userId;
|
|
|
const t = ctx.meta.t;
|
|
|
|
|
|
const userInfo = await call(ctx).getUserInfo(userId);
|
|
|
if (userInfo.emailVerified === true) {
|
|
|
throw new Error(t('邮箱已认证'));
|
|
|
}
|
|
|
|
|
|
// 检查
|
|
|
const cacheKey = this.buildVerifyEmailKey(userInfo.email);
|
|
|
const cachedOTP = await this.broker.cacher.get(cacheKey);
|
|
|
if (!cachedOTP) {
|
|
|
throw new Error(t('校验失败, OTP已过期'));
|
|
|
}
|
|
|
if (String(cachedOTP) !== emailOTP) {
|
|
|
throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP'));
|
|
|
}
|
|
|
|
|
|
// 验证通过
|
|
|
const user = await this.adapter.model.findOneAndUpdate(
|
|
|
{
|
|
|
_id: new Types.ObjectId(userId),
|
|
|
},
|
|
|
{
|
|
|
emailVerified: true,
|
|
|
},
|
|
|
{
|
|
|
new: true,
|
|
|
}
|
|
|
);
|
|
|
|
|
|
await this.cleanCurrentUserCache(ctx);
|
|
|
|
|
|
return this.transformDocuments(ctx, {}, user);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 用户注册
|
|
|
*/
|
|
|
async register(
|
|
|
ctx: TcPureContext<
|
|
|
{
|
|
|
username?: string;
|
|
|
email?: string;
|
|
|
password: string;
|
|
|
emailOTP?: string;
|
|
|
},
|
|
|
any
|
|
|
>
|
|
|
) {
|
|
|
const params = { ...ctx.params };
|
|
|
const t = ctx.meta.t;
|
|
|
await this.validateEntity(params);
|
|
|
|
|
|
await this.validateRegisterParams(params, t);
|
|
|
|
|
|
if (config.feature.disableUserRegister) {
|
|
|
throw new Error(t('服务器不允许新用户注册'));
|
|
|
}
|
|
|
|
|
|
const nickname = params.username ?? getEmailAddress(params.email);
|
|
|
const discriminator = await this.adapter.model.generateDiscriminator(
|
|
|
nickname
|
|
|
);
|
|
|
|
|
|
let emailVerified = false;
|
|
|
if (config.emailVerification === true) {
|
|
|
// 检查OTP
|
|
|
const cacheKey = this.buildVerifyEmailKey(params.email);
|
|
|
const cachedOTP = await this.broker.cacher.get(cacheKey);
|
|
|
|
|
|
if (!cachedOTP) {
|
|
|
throw new Error(t('校验失败, OTP已过期'));
|
|
|
}
|
|
|
|
|
|
if (String(cachedOTP) !== params.emailOTP) {
|
|
|
throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP'));
|
|
|
}
|
|
|
|
|
|
emailVerified = true;
|
|
|
}
|
|
|
|
|
|
const password = await this.hashPassword(params.password);
|
|
|
const doc = await this.adapter.insert({
|
|
|
...params,
|
|
|
password,
|
|
|
nickname,
|
|
|
discriminator,
|
|
|
emailVerified,
|
|
|
avatar: null,
|
|
|
createdAt: new Date(),
|
|
|
});
|
|
|
const user = await this.transformDocuments(ctx, {}, doc);
|
|
|
const json = await this.transformEntity(user, true, ctx.meta.token);
|
|
|
await this.entityChanged('created', json, ctx);
|
|
|
return json;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 修改密码
|
|
|
*/
|
|
|
async modifyPassword(
|
|
|
ctx: TcContext<{
|
|
|
oldPassword: string;
|
|
|
newPassword: string;
|
|
|
}>
|
|
|
) {
|
|
|
const { oldPassword, newPassword } = ctx.params;
|
|
|
const { userId, t } = ctx.meta;
|
|
|
|
|
|
const user = await this.adapter.model.findById(userId);
|
|
|
if (!user) {
|
|
|
throw new Error(t('用户不存在'));
|
|
|
}
|
|
|
|
|
|
const oldPasswordMatched = await this.comparePassword(
|
|
|
oldPassword,
|
|
|
user.password
|
|
|
);
|
|
|
if (!oldPasswordMatched) {
|
|
|
throw new Error(t('密码不正确'));
|
|
|
}
|
|
|
|
|
|
user.password = await this.hashPassword(newPassword);
|
|
|
await user.save();
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 创建临时用户
|
|
|
*/
|
|
|
async createTemporaryUser(ctx: TcPureContext<{ nickname: string }>) {
|
|
|
const nickname = ctx.params.nickname;
|
|
|
const t = ctx.meta.t;
|
|
|
|
|
|
if (config.feature.disableGuestLogin) {
|
|
|
throw new Error(t('服务器不允许游客登录'));
|
|
|
}
|
|
|
|
|
|
const discriminator = await this.adapter.model.generateDiscriminator(
|
|
|
nickname
|
|
|
);
|
|
|
|
|
|
const password = await this.hashPassword(generateRandomStr());
|
|
|
const doc = await this.adapter.insert({
|
|
|
email: `${generateRandomStr()}.temporary@msgbyte.com`,
|
|
|
password,
|
|
|
nickname,
|
|
|
discriminator,
|
|
|
temporary: true,
|
|
|
avatar: null,
|
|
|
createdAt: new Date(),
|
|
|
});
|
|
|
const user = await this.transformDocuments(ctx, {}, doc);
|
|
|
const json = await this.transformEntity(user, true);
|
|
|
await this.entityChanged('created', json, ctx);
|
|
|
|
|
|
return json;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 认领临时用户
|
|
|
*/
|
|
|
async claimTemporaryUser(
|
|
|
ctx: TcPureContext<{
|
|
|
userId: string;
|
|
|
username?: string;
|
|
|
email: string;
|
|
|
password: string;
|
|
|
emailOTP?: string;
|
|
|
}>
|
|
|
) {
|
|
|
const params = ctx.params;
|
|
|
const t = ctx.meta.t;
|
|
|
|
|
|
const user = await this.adapter.findById(params.userId);
|
|
|
if (!user) {
|
|
|
throw new DataNotFoundError(t('认领用户不存在'));
|
|
|
}
|
|
|
if (!user.temporary) {
|
|
|
throw new Error(t('该用户不是临时用户'));
|
|
|
}
|
|
|
|
|
|
if (config.emailVerification === true) {
|
|
|
// 检查OTP
|
|
|
const cacheKey = this.buildVerifyEmailKey(params.email);
|
|
|
const cachedOTP = await this.broker.cacher.get(cacheKey);
|
|
|
|
|
|
if (!cachedOTP) {
|
|
|
throw new Error(t('校验失败, OTP已过期'));
|
|
|
}
|
|
|
|
|
|
if (String(cachedOTP) !== params.emailOTP) {
|
|
|
throw new Error(t('邮箱校验失败, 请输入正确的邮箱OTP'));
|
|
|
}
|
|
|
|
|
|
user.emailVerified = true;
|
|
|
}
|
|
|
|
|
|
await this.validateRegisterParams(params, t);
|
|
|
const password = await this.hashPassword(params.password);
|
|
|
|
|
|
user.username = params.username;
|
|
|
user.email = params.email;
|
|
|
user.password = password;
|
|
|
user.temporary = false;
|
|
|
await user.save();
|
|
|
|
|
|
const json = await this.transformEntity(user, true);
|
|
|
await this.entityChanged('updated', json, ctx);
|
|
|
return json;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 忘记密码
|
|
|
*
|
|
|
* 流程: 发送一个链接到远程,点开后可以直接重置密码
|
|
|
*/
|
|
|
async forgetPassword(
|
|
|
ctx: TcPureContext<{
|
|
|
email: string;
|
|
|
}>
|
|
|
) {
|
|
|
const { email } = ctx.params;
|
|
|
const { t } = ctx.meta;
|
|
|
const cacheKey = `forget-password:${email}`;
|
|
|
|
|
|
const c = await this.broker.cacher.get(cacheKey);
|
|
|
if (!!c) {
|
|
|
// 如果有一个忘记密码请求未到期
|
|
|
throw new Error(t('过于频繁的请求,10 分钟内可以共用同一OTP'));
|
|
|
}
|
|
|
|
|
|
const otp = generateRandomNumStr(6); // 产生一次性6位数字密码
|
|
|
|
|
|
const html = `
|
|
|
<p>忘记密码了? 请使用以下 OTP 作为重置密码凭证:</p>
|
|
|
<h3>OTP: <strong>${otp}</strong></h3>
|
|
|
<p>该 OTP 将会在 10分钟 后过期</p>
|
|
|
<p style="color: grey;">如果并不是您触发的忘记密码操作,请忽略此电子邮件。</p>`;
|
|
|
|
|
|
await ctx.call('mail.sendMail', {
|
|
|
to: email,
|
|
|
subject: `Tailchat 忘记密码: ${otp}`,
|
|
|
html,
|
|
|
});
|
|
|
|
|
|
await this.broker.cacher.set(cacheKey, otp, 10 * 60); // 记录该OTP ttl: 10分钟
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 重置密码
|
|
|
*/
|
|
|
async resetPassword(
|
|
|
ctx: TcPureContext<{
|
|
|
email: string;
|
|
|
password: string;
|
|
|
otp: string;
|
|
|
}>
|
|
|
) {
|
|
|
const { email, password, otp } = ctx.params;
|
|
|
const { t } = ctx.meta;
|
|
|
const cacheKey = `forget-password:${email}`;
|
|
|
|
|
|
const cachedOTP = await this.broker.cacher.get(cacheKey);
|
|
|
|
|
|
if (!cachedOTP) {
|
|
|
throw new Error(t('校验失败, OTP已过期'));
|
|
|
}
|
|
|
|
|
|
if (String(cachedOTP) !== otp) {
|
|
|
throw new Error(t('OTP 不正确'));
|
|
|
}
|
|
|
|
|
|
const res = await this.adapter.model.updateOne(
|
|
|
{
|
|
|
email,
|
|
|
},
|
|
|
{
|
|
|
password: await this.hashPassword(password),
|
|
|
}
|
|
|
);
|
|
|
|
|
|
if (res.modifiedCount === 0) {
|
|
|
throw new Error(t('账号不存在'));
|
|
|
}
|
|
|
|
|
|
await this.broker.cacher.clean(cacheKey);
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 校验JWT的合法性
|
|
|
* @param ctx
|
|
|
* @returns
|
|
|
*/
|
|
|
async resolveToken(ctx: PureContext<{ token: string }, any>) {
|
|
|
const decoded = await this.verifyJWT(ctx.params.token);
|
|
|
const t = ctx.meta.t;
|
|
|
|
|
|
if (typeof decoded._id !== 'string') {
|
|
|
// token 中没有 _id
|
|
|
throw new EntityError(t('Token 内容不正确'));
|
|
|
}
|
|
|
const doc = await this.getById(decoded._id);
|
|
|
const user: User = await this.transformDocuments(ctx, {}, doc);
|
|
|
|
|
|
if (user.banned === true) {
|
|
|
throw new NoPermissionError(t('用户被封禁'));
|
|
|
}
|
|
|
|
|
|
const json = await this.transformEntity(user, true, ctx.meta.token);
|
|
|
return json;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 检查授权是否可用
|
|
|
*/
|
|
|
async checkTokenValid(ctx: PureContext<{ token: string }>) {
|
|
|
try {
|
|
|
await this.verifyJWT(ctx.params.token);
|
|
|
|
|
|
return true;
|
|
|
} catch (e) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async whoami(ctx: TcContext) {
|
|
|
return ctx.meta ?? null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 搜索用户
|
|
|
*
|
|
|
*/
|
|
|
async searchUserWithUniqueName(ctx: TcContext<{ uniqueName: string }>) {
|
|
|
const t = ctx.meta.t;
|
|
|
const uniqueName = ctx.params.uniqueName;
|
|
|
if (!uniqueName.includes('#')) {
|
|
|
throw new EntityError(t('请输入带唯一标识的用户名 如: Nickname#0000'));
|
|
|
}
|
|
|
|
|
|
const [nickname, discriminator] = uniqueName.split('#');
|
|
|
const doc = await this.adapter.findOne({
|
|
|
nickname,
|
|
|
discriminator,
|
|
|
});
|
|
|
const user = await this.transformDocuments(ctx, {}, doc);
|
|
|
|
|
|
return user;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取用户信息
|
|
|
*/
|
|
|
async getUserInfo(ctx: PureContext<{ userId: string }>) {
|
|
|
const userId = ctx.params.userId;
|
|
|
|
|
|
const doc = await this.adapter.findById(userId);
|
|
|
const user = await this.transformDocuments(ctx, {}, doc);
|
|
|
|
|
|
return user;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取用户信息的批量操作版
|
|
|
* 用于优化网络访问性能
|
|
|
*/
|
|
|
async getUserInfoList(ctx: PureContext<{ userIds: string[] }>) {
|
|
|
const userIds = ctx.params.userIds;
|
|
|
|
|
|
if (userIds.some((userId) => !isValidObjectId(userId))) {
|
|
|
throw new EntityError('Include invalid userId');
|
|
|
}
|
|
|
const list = await Promise.all(
|
|
|
userIds.map((userId) =>
|
|
|
ctx.call('user.getUserInfo', {
|
|
|
userId,
|
|
|
})
|
|
|
)
|
|
|
);
|
|
|
|
|
|
return list;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 修改用户字段
|
|
|
*/
|
|
|
async updateUserField(
|
|
|
ctx: TcContext<{ fieldName: string; fieldValue: string }>
|
|
|
) {
|
|
|
const { fieldName, fieldValue } = ctx.params;
|
|
|
const t = ctx.meta.t;
|
|
|
const userId = ctx.meta.userId;
|
|
|
if (!['nickname', 'avatar'].includes(fieldName)) {
|
|
|
// 只允许修改以上字段
|
|
|
throw new EntityError(`${t('该数据不允许修改')}: ${fieldName}`);
|
|
|
}
|
|
|
|
|
|
const doc = await this.adapter.model
|
|
|
.findOneAndUpdate(
|
|
|
{
|
|
|
_id: new Types.ObjectId(userId),
|
|
|
},
|
|
|
{
|
|
|
[fieldName]: fieldValue,
|
|
|
},
|
|
|
{
|
|
|
new: true,
|
|
|
}
|
|
|
)
|
|
|
.exec();
|
|
|
|
|
|
this.cleanCurrentUserCache(ctx);
|
|
|
|
|
|
return await this.transformDocuments(ctx, {}, doc);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 修改用户额外数据
|
|
|
*/
|
|
|
async updateUserExtra(
|
|
|
ctx: TcContext<{ fieldName: string; fieldValue: string }>
|
|
|
) {
|
|
|
const { fieldName, fieldValue } = ctx.params;
|
|
|
const userId = ctx.meta.userId;
|
|
|
|
|
|
const doc = await this.adapter.model
|
|
|
.findOneAndUpdate(
|
|
|
{
|
|
|
_id: new Types.ObjectId(userId),
|
|
|
},
|
|
|
{
|
|
|
[`extra.${fieldName}`]: fieldValue,
|
|
|
},
|
|
|
{
|
|
|
new: true,
|
|
|
}
|
|
|
)
|
|
|
.exec();
|
|
|
|
|
|
this.cleanCurrentUserCache(ctx);
|
|
|
|
|
|
return await this.transformDocuments(ctx, {}, doc);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取用户个人配置
|
|
|
*/
|
|
|
async getUserSettings(ctx: TcContext<{}>) {
|
|
|
const { userId } = ctx.meta;
|
|
|
|
|
|
const user: UserDocument = await this.adapter.model.findOne(
|
|
|
{
|
|
|
_id: new Types.ObjectId(userId),
|
|
|
},
|
|
|
{
|
|
|
settings: 1,
|
|
|
}
|
|
|
);
|
|
|
|
|
|
return user.settings;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 设置用户个人配置
|
|
|
*/
|
|
|
async setUserSettings(ctx: TcContext<{ settings: object }>) {
|
|
|
const { settings } = ctx.params;
|
|
|
const { userId } = ctx.meta;
|
|
|
|
|
|
const user: UserDocument = await this.adapter.model.findOneAndUpdate(
|
|
|
{
|
|
|
_id: new Types.ObjectId(userId),
|
|
|
},
|
|
|
{
|
|
|
$set: {
|
|
|
..._.mapKeys(settings, (value, key) => `settings.${key}`),
|
|
|
},
|
|
|
},
|
|
|
{ new: true }
|
|
|
);
|
|
|
|
|
|
if (!user) {
|
|
|
throw new Error('User not found');
|
|
|
}
|
|
|
|
|
|
return user.settings;
|
|
|
}
|
|
|
|
|
|
async ensurePluginBot(
|
|
|
ctx: TcContext<{
|
|
|
botId: string;
|
|
|
nickname: string;
|
|
|
avatar: string;
|
|
|
}>
|
|
|
): Promise<string> {
|
|
|
const { botId, nickname, avatar } = ctx.params;
|
|
|
const email = this.buildPluginBotEmail(botId);
|
|
|
|
|
|
const bot = await this.adapter.model.findOne({
|
|
|
email,
|
|
|
});
|
|
|
|
|
|
if (bot) {
|
|
|
if (bot.nickname !== nickname || bot.avatar !== avatar) {
|
|
|
/**
|
|
|
* 如果信息不匹配,则更新
|
|
|
*/
|
|
|
this.logger.info('检查到插件机器人信息不匹配, 更新机器人信息:', {
|
|
|
nickname,
|
|
|
avatar,
|
|
|
});
|
|
|
await bot.updateOne({
|
|
|
nickname,
|
|
|
avatar,
|
|
|
});
|
|
|
await this.cleanUserInfoCache(String(bot._id));
|
|
|
}
|
|
|
|
|
|
return String(bot._id);
|
|
|
}
|
|
|
|
|
|
// 如果不存在,则创建
|
|
|
const newBot = await this.adapter.model.create({
|
|
|
email,
|
|
|
nickname,
|
|
|
avatar,
|
|
|
type: 'pluginBot',
|
|
|
});
|
|
|
|
|
|
return String(newBot._id);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 确保第三方开放平台机器人存在
|
|
|
*/
|
|
|
async ensureOpenapiBot(
|
|
|
ctx: TcContext<{
|
|
|
botId: string;
|
|
|
nickname: string;
|
|
|
avatar: string;
|
|
|
}>
|
|
|
): Promise<{
|
|
|
_id: string;
|
|
|
email: string;
|
|
|
nickname: string;
|
|
|
avatar: string;
|
|
|
}> {
|
|
|
const { botId, nickname, avatar } = ctx.params;
|
|
|
const email = this.buildOpenapiBotEmail(botId);
|
|
|
|
|
|
const bot = await this.adapter.model.findOne({
|
|
|
email,
|
|
|
});
|
|
|
|
|
|
if (bot) {
|
|
|
if (bot.nickname !== nickname || bot.avatar !== avatar) {
|
|
|
/**
|
|
|
* 如果信息不匹配,则更新
|
|
|
*/
|
|
|
this.logger.info('检查到第三方机器人信息不匹配, 更新机器人信息:', {
|
|
|
nickname,
|
|
|
avatar,
|
|
|
});
|
|
|
await bot.updateOne({
|
|
|
nickname,
|
|
|
avatar,
|
|
|
});
|
|
|
await this.cleanUserInfoCache(String(bot._id));
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
_id: String(bot._id),
|
|
|
email,
|
|
|
nickname,
|
|
|
avatar,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// 如果不存在,则创建
|
|
|
const newBot = await this.adapter.model.create({
|
|
|
email,
|
|
|
nickname,
|
|
|
avatar,
|
|
|
type: 'openapiBot',
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
_id: String(newBot._id),
|
|
|
email,
|
|
|
nickname,
|
|
|
avatar,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 根据用户邮件获取开放平台机器人id
|
|
|
*/
|
|
|
findOpenapiBotId(ctx: TcContext<{ email: string }>): string {
|
|
|
return this.parseOpenapiBotEmail(ctx.params.email);
|
|
|
}
|
|
|
|
|
|
async generateUserToken(
|
|
|
ctx: TcContext<{
|
|
|
userId: string;
|
|
|
nickname: string;
|
|
|
email: string;
|
|
|
avatar: string;
|
|
|
}>
|
|
|
) {
|
|
|
const { userId, nickname, email, avatar } = ctx.params;
|
|
|
|
|
|
const token = this.generateJWT({
|
|
|
_id: userId,
|
|
|
nickname,
|
|
|
email,
|
|
|
avatar,
|
|
|
});
|
|
|
|
|
|
return token;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 清理当前用户的缓存信息
|
|
|
*/
|
|
|
private async cleanCurrentUserCache(ctx: TcContext) {
|
|
|
const { token, userId } = ctx.meta;
|
|
|
await Promise.all([
|
|
|
this.cleanActionCache('resolveToken', [token]),
|
|
|
this.cleanActionCache('getUserInfo', [userId]),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 根据用户ID清理缓存信息
|
|
|
*/
|
|
|
private async cleanUserInfoCache(userId: string) {
|
|
|
await this.cleanActionCache('getUserInfo', [String(userId)]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Transform returned user entity. Generate JWT token if neccessary.
|
|
|
*
|
|
|
* @param {Object} user
|
|
|
* @param {Boolean} withToken
|
|
|
*/
|
|
|
private async transformEntity(user: any, withToken: boolean, token?: string) {
|
|
|
if (user) {
|
|
|
//user.avatar = user.avatar || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
|
|
|
if (withToken) {
|
|
|
if (token !== undefined) {
|
|
|
// 携带了token
|
|
|
try {
|
|
|
await this.verifyJWT(token);
|
|
|
// token 可用, 原样传回
|
|
|
user.token = token;
|
|
|
} catch (err) {
|
|
|
// token 不可用, 生成一个新的返回
|
|
|
user.token = this.generateJWT(user);
|
|
|
}
|
|
|
} else {
|
|
|
// 没有携带token 生成一个
|
|
|
user.token = this.generateJWT(user);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return user;
|
|
|
}
|
|
|
|
|
|
private async verifyJWT(token: string): Promise<UserJWTPayload> {
|
|
|
const decoded = await new Promise<UserJWTPayload>((resolve, reject) => {
|
|
|
jwt.verify(token, this.jwtSecretKey, (err, decoded: UserJWTPayload) => {
|
|
|
if (err) return reject(err);
|
|
|
|
|
|
resolve(decoded);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
return decoded;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 生成jwt
|
|
|
*/
|
|
|
private generateJWT(user: {
|
|
|
_id: string;
|
|
|
nickname: string;
|
|
|
email: string;
|
|
|
avatar: string;
|
|
|
}): string {
|
|
|
return jwt.sign(
|
|
|
{
|
|
|
_id: user._id,
|
|
|
nickname: user.nickname,
|
|
|
email: user.email,
|
|
|
avatar: user.avatar,
|
|
|
} as UserJWTPayload,
|
|
|
this.jwtSecretKey,
|
|
|
{
|
|
|
expiresIn: '30d',
|
|
|
}
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 校验参数合法性
|
|
|
*/
|
|
|
private async validateRegisterParams(
|
|
|
params: {
|
|
|
username?: string;
|
|
|
email?: string;
|
|
|
},
|
|
|
t: TFunction
|
|
|
) {
|
|
|
if (!params.username && !params.email) {
|
|
|
throw new Errors.ValidationError(t('用户名或邮箱为空'));
|
|
|
}
|
|
|
|
|
|
if (params.username) {
|
|
|
const found = await this.adapter.findOne({ username: params.username });
|
|
|
if (found) {
|
|
|
throw new Errors.MoleculerClientError(t('用户名已存在!'), 422, '', [
|
|
|
{ field: 'username', message: 'is exist' },
|
|
|
]);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (params.email) {
|
|
|
const found = await this.adapter.findOne({ email: params.email });
|
|
|
if (found) {
|
|
|
throw new Errors.MoleculerClientError(t('邮箱已存在!'), 422, '', [
|
|
|
{ field: 'email', message: 'is exist' },
|
|
|
]);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private buildPluginBotEmail(botId: string) {
|
|
|
return `${botId}@tailchat-plugin.com`;
|
|
|
}
|
|
|
|
|
|
private buildOpenapiBotEmail(botId: string) {
|
|
|
return `${botId}@tailchat-openapi.com`;
|
|
|
}
|
|
|
|
|
|
private parseOpenapiBotEmail(email: string): string | null {
|
|
|
if (email.endsWith('@tailchat-openapi.com')) {
|
|
|
return email.replace('@tailchat-openapi.com', '');
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 构建验证邮箱的缓存key
|
|
|
*/
|
|
|
private buildVerifyEmailKey(email: string) {
|
|
|
return `verify-email:${email}`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export default UserService;
|