优化
This commit is contained in:
108
server/services/core/chat/ack.service.ts
Normal file
108
server/services/core/chat/ack.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Types } from 'mongoose';
|
||||
import type { AckDocument, AckModel } from '../../../models/chat/ack';
|
||||
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
|
||||
|
||||
/**
|
||||
* 消息已读管理
|
||||
*/
|
||||
|
||||
interface AckService extends TcService, TcDbService<AckDocument, AckModel> {}
|
||||
class AckService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'chat.ack';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/chat/ack').default);
|
||||
// Public fields
|
||||
this.registerDbField(['userId', 'converseId', 'lastMessageId']);
|
||||
|
||||
this.registerAction('update', this.updateAck, {
|
||||
params: {
|
||||
converseId: 'string',
|
||||
lastMessageId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('list', this.listAck, {
|
||||
params: {
|
||||
converseIds: {
|
||||
type: 'array',
|
||||
items: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
this.registerAction('all', this.allAck);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户在会话中已读的最后一条消息
|
||||
*/
|
||||
async updateAck(
|
||||
ctx: TcContext<{
|
||||
converseId: string;
|
||||
lastMessageId: string;
|
||||
}>
|
||||
) {
|
||||
const { converseId, lastMessageId } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
await this.adapter.model.updateOne(
|
||||
{
|
||||
converseId,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
lastMessageId: new Types.ObjectId(lastMessageId),
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: 如果要实现消息已读可以在此处基于会话id进行通知
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有的ack信息
|
||||
*/
|
||||
async listAck(ctx: TcContext<{ converseIds: string[] }>) {
|
||||
const userId = ctx.meta.userId;
|
||||
const { converseIds } = ctx.params;
|
||||
|
||||
const list = await this.adapter.model.find({
|
||||
userId,
|
||||
converseId: {
|
||||
$in: [...converseIds],
|
||||
},
|
||||
});
|
||||
|
||||
return converseIds.map((converseId) => {
|
||||
const lastMessageId =
|
||||
list
|
||||
.find((item) => String(item.converseId) === converseId)
|
||||
?.lastMessageId?.toString() ?? null;
|
||||
|
||||
return lastMessageId
|
||||
? {
|
||||
converseId,
|
||||
lastMessageId,
|
||||
}
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有的ack信息
|
||||
*/
|
||||
async allAck(ctx: TcContext) {
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const list = await this.adapter.model.find({
|
||||
userId,
|
||||
});
|
||||
|
||||
return await this.transformDocuments(ctx, {}, list);
|
||||
}
|
||||
}
|
||||
|
||||
export default AckService;
|
||||
273
server/services/core/chat/converse.service.ts
Normal file
273
server/services/core/chat/converse.service.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import _ from 'lodash';
|
||||
import { Types } from 'mongoose';
|
||||
import {
|
||||
TcDbService,
|
||||
TcService,
|
||||
TcContext,
|
||||
UserStruct,
|
||||
call,
|
||||
DataNotFoundError,
|
||||
NoPermissionError,
|
||||
SYSTEM_USERID,
|
||||
} from 'tailchat-server-sdk';
|
||||
import type {
|
||||
ConverseDocument,
|
||||
ConverseModel,
|
||||
} from '../../../models/chat/converse';
|
||||
|
||||
interface ConverseService
|
||||
extends TcService,
|
||||
TcDbService<ConverseDocument, ConverseModel> {}
|
||||
class ConverseService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'chat.converse';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/chat/converse').default);
|
||||
|
||||
this.registerAction('createDMConverse', this.createDMConverse, {
|
||||
params: {
|
||||
/**
|
||||
* 创建私人会话的参与者ID列表
|
||||
*/
|
||||
memberIds: { type: 'array', items: 'string' },
|
||||
},
|
||||
});
|
||||
this.registerAction(
|
||||
'appendDMConverseMembers',
|
||||
this.appendDMConverseMembers,
|
||||
{
|
||||
params: {
|
||||
converseId: 'string',
|
||||
memberIds: 'array',
|
||||
},
|
||||
}
|
||||
);
|
||||
this.registerAction('findConverseInfo', this.findConverseInfo, {
|
||||
params: {
|
||||
converseId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('findAndJoinRoom', this.findAndJoinRoom);
|
||||
}
|
||||
|
||||
async createDMConverse(ctx: TcContext<{ memberIds: string[] }>) {
|
||||
const userId = ctx.meta.userId;
|
||||
const memberIds = ctx.params.memberIds;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
const participantList = _.uniq([userId, ...memberIds]);
|
||||
|
||||
if (participantList.length < 2) {
|
||||
throw new Error(t('成员数异常,无法创建会话'));
|
||||
}
|
||||
|
||||
let converse: ConverseDocument;
|
||||
if (participantList.length === 2) {
|
||||
// 私信会话
|
||||
converse = await this.adapter.model.findConverseWithMembers(
|
||||
participantList
|
||||
);
|
||||
if (converse === null) {
|
||||
// 创建新的会话
|
||||
converse = await this.adapter.model.create({
|
||||
type: 'DM',
|
||||
members: participantList.map((id) => new Types.ObjectId(id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (participantList.length > 2) {
|
||||
// 多人会话
|
||||
converse = await this.adapter.model.create({
|
||||
type: 'Multi',
|
||||
members: participantList.map((id) => new Types.ObjectId(id)),
|
||||
});
|
||||
}
|
||||
|
||||
const roomId = String(converse._id);
|
||||
await Promise.all(
|
||||
participantList.map((memberId) =>
|
||||
call(ctx).joinSocketIORoom([roomId], memberId)
|
||||
)
|
||||
);
|
||||
|
||||
// 广播更新消息
|
||||
await this.roomcastNotify(
|
||||
ctx,
|
||||
roomId,
|
||||
'updateDMConverse',
|
||||
converse.toJSON()
|
||||
);
|
||||
|
||||
// 更新dmlist 异步处理
|
||||
Promise.all(
|
||||
participantList.map(async (memberId) => {
|
||||
try {
|
||||
await ctx.call(
|
||||
'user.dmlist.addConverse',
|
||||
{ converseId: roomId },
|
||||
{
|
||||
meta: {
|
||||
userId: memberId,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (participantList.length > 2) {
|
||||
// 如果创建的是一个多人会话(非双人), 发送系统消息
|
||||
await Promise.all(
|
||||
_.without(participantList, userId).map<Promise<UserStruct>>(
|
||||
(memberId) => call(ctx).getUserInfo(memberId)
|
||||
)
|
||||
).then((infoList) => {
|
||||
return call(ctx).sendSystemMessage(
|
||||
t('{{user}} 邀请 {{others}} 加入会话', {
|
||||
user: ctx.meta.user.nickname,
|
||||
others: infoList.map((info) => info.nickname).join(', '),
|
||||
}),
|
||||
roomId
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return await this.transformDocuments(ctx, {}, converse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在多人会话中添加成员
|
||||
*/
|
||||
async appendDMConverseMembers(
|
||||
ctx: TcContext<{ converseId: string; memberIds: string[] }>
|
||||
) {
|
||||
const userId = ctx.meta.userId;
|
||||
const { converseId, memberIds } = ctx.params;
|
||||
|
||||
const converse = await this.adapter.model.findById(converseId);
|
||||
if (!converse) {
|
||||
throw new DataNotFoundError();
|
||||
}
|
||||
|
||||
if (!converse.members.map(String).includes(userId)) {
|
||||
throw new Error('不是会话参与者, 无法添加成员');
|
||||
}
|
||||
|
||||
converse.members.push(...memberIds.map((uid) => new Types.ObjectId(uid)));
|
||||
await converse.save();
|
||||
|
||||
await Promise.all(
|
||||
memberIds.map((uid) =>
|
||||
call(ctx).joinSocketIORoom([String(converseId)], uid)
|
||||
)
|
||||
);
|
||||
|
||||
// 广播更新会话列表
|
||||
await this.roomcastNotify(
|
||||
ctx,
|
||||
converseId,
|
||||
'updateDMConverse',
|
||||
converse.toJSON()
|
||||
);
|
||||
|
||||
// 更新dmlist 异步处理
|
||||
Promise.all(
|
||||
memberIds.map(async (memberId) => {
|
||||
try {
|
||||
await ctx.call(
|
||||
'user.dmlist.addConverse',
|
||||
{ converseId },
|
||||
{
|
||||
meta: {
|
||||
userId: memberId,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 发送系统消息, 异步处理
|
||||
await Promise.all(
|
||||
memberIds.map<Promise<UserStruct>>((memberId) =>
|
||||
ctx.call('user.getUserInfo', { userId: memberId })
|
||||
)
|
||||
).then((infoList) => {
|
||||
return call(ctx).sendSystemMessage(
|
||||
`${ctx.meta.user.nickname} 邀请 ${infoList
|
||||
.map((info) => info.nickname)
|
||||
.join(', ')} 加入会话`,
|
||||
converseId
|
||||
);
|
||||
});
|
||||
|
||||
return converse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找会话
|
||||
*/
|
||||
async findConverseInfo(
|
||||
ctx: TcContext<{
|
||||
converseId: string;
|
||||
}>
|
||||
) {
|
||||
const converseId = ctx.params.converseId;
|
||||
const userId = ctx.meta.userId;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
const converse = await this.adapter.findById(converseId);
|
||||
|
||||
if (userId !== SYSTEM_USERID) {
|
||||
// not system, check permission
|
||||
const memebers = converse.members ?? [];
|
||||
if (!memebers.map((member) => String(member)).includes(userId)) {
|
||||
throw new NoPermissionError(t('没有获取会话信息权限'));
|
||||
}
|
||||
}
|
||||
|
||||
return await this.transformDocuments(ctx, {}, converse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找用户相关的所有会话并加入房间
|
||||
* @returns 返回相关信息
|
||||
*/
|
||||
async findAndJoinRoom(ctx: TcContext) {
|
||||
const userId = ctx.meta.userId;
|
||||
const dmConverseIds = await this.adapter.model.findAllJoinedConverseId(
|
||||
userId
|
||||
);
|
||||
|
||||
// 获取群组列表
|
||||
const { groupIds, textPanelIds, subscribeFeaturePanelIds } =
|
||||
await ctx.call<{
|
||||
groupIds: string[];
|
||||
textPanelIds: string[];
|
||||
subscribeFeaturePanelIds: string[];
|
||||
}>('group.getJoinedGroupAndPanelIds');
|
||||
|
||||
await call(ctx).joinSocketIORoom([
|
||||
...dmConverseIds,
|
||||
...groupIds,
|
||||
...textPanelIds,
|
||||
...subscribeFeaturePanelIds,
|
||||
]);
|
||||
|
||||
return {
|
||||
dmConverseIds,
|
||||
groupIds,
|
||||
textPanelIds,
|
||||
subscribeFeaturePanelIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ConverseService;
|
||||
281
server/services/core/chat/inbox.service.ts
Normal file
281
server/services/core/chat/inbox.service.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { InboxDocument, InboxModel } from '../../../models/chat/inbox';
|
||||
import {
|
||||
TcService,
|
||||
TcContext,
|
||||
TcDbService,
|
||||
TcPureContext,
|
||||
InboxStruct,
|
||||
} from 'tailchat-server-sdk';
|
||||
import pMap from 'p-map';
|
||||
|
||||
/**
|
||||
* 收件箱管理
|
||||
*/
|
||||
interface InboxService
|
||||
extends TcService,
|
||||
TcDbService<InboxDocument, InboxModel> {}
|
||||
class InboxService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'chat.inbox';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/chat/inbox').default);
|
||||
|
||||
this.registerEventListener(
|
||||
'chat.message.updateMessage',
|
||||
async (payload, ctx) => {
|
||||
if (
|
||||
Array.isArray(payload.meta.mentions) &&
|
||||
payload.meta.mentions.length > 0
|
||||
) {
|
||||
const mentions = payload.meta.mentions;
|
||||
if (payload.type === 'add') {
|
||||
await Promise.all(
|
||||
mentions.map((userId) => {
|
||||
return ctx.call('chat.inbox.append', {
|
||||
userId,
|
||||
type: 'message',
|
||||
payload: {
|
||||
groupId: payload.groupId,
|
||||
converseId: payload.converseId,
|
||||
messageId: payload.messageId,
|
||||
messageAuthor: payload.author,
|
||||
messageSnippet: payload.content,
|
||||
messagePlainContent: payload.plain,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
} else if (payload.type === 'delete') {
|
||||
await Promise.all(
|
||||
mentions.map((userId) => {
|
||||
return ctx.call('chat.inbox.removeMessage', {
|
||||
userId,
|
||||
groupId: payload.groupId,
|
||||
converseId: payload.converseId,
|
||||
messageId: payload.messageId,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.registerAction('append', this.append, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
userId: { type: 'string', optional: true },
|
||||
type: 'string',
|
||||
payload: 'any',
|
||||
},
|
||||
});
|
||||
this.registerAction('batchAppend', this.batchAppend, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
userIds: { type: 'array', items: 'string' },
|
||||
type: 'string',
|
||||
payload: 'any',
|
||||
},
|
||||
});
|
||||
this.registerAction('removeMessage', this.removeMessage, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
userId: { type: 'string', optional: true },
|
||||
groupId: { type: 'string', optional: true },
|
||||
converseId: 'string',
|
||||
messageId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('all', this.all);
|
||||
this.registerAction('ack', this.ack, {
|
||||
params: {
|
||||
inboxItemIds: { type: 'array', items: 'string' },
|
||||
},
|
||||
});
|
||||
this.registerAction('clear', this.clear);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的增加inbox的接口
|
||||
* 用于内部,插件化的形式
|
||||
*/
|
||||
async append(
|
||||
ctx: TcContext<{
|
||||
userId?: string;
|
||||
type: string;
|
||||
payload: Record<string, any>;
|
||||
}>
|
||||
) {
|
||||
const { userId = ctx.meta.userId, type, payload } = ctx.params;
|
||||
|
||||
const doc = await this.adapter.model.create({
|
||||
userId,
|
||||
type,
|
||||
payload,
|
||||
});
|
||||
|
||||
const inboxItem = await this.transformDocuments(ctx, {}, doc);
|
||||
|
||||
await this.notifyUsersInboxAppend(ctx, [userId], inboxItem);
|
||||
await this.emitInboxAppendEvent(ctx, inboxItem);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* append 的多用户版本
|
||||
*/
|
||||
async batchAppend(
|
||||
ctx: TcContext<{
|
||||
userIds: string[];
|
||||
type: string;
|
||||
payload: Record<string, any>;
|
||||
}>
|
||||
) {
|
||||
const { userIds, type, payload } = ctx.params;
|
||||
|
||||
const docs = await this.adapter.model.create(
|
||||
userIds.map((userId) => ({
|
||||
userId,
|
||||
type,
|
||||
payload,
|
||||
}))
|
||||
);
|
||||
|
||||
const inboxItems: InboxStruct[] = await this.transformDocuments(
|
||||
ctx,
|
||||
{},
|
||||
docs
|
||||
);
|
||||
|
||||
pMap(
|
||||
inboxItems,
|
||||
async (inboxItem) => {
|
||||
await Promise.all([
|
||||
this.notifyUsersInboxAppend(ctx, [inboxItem.userId], inboxItem),
|
||||
this.emitInboxAppendEvent(ctx, inboxItem),
|
||||
]);
|
||||
},
|
||||
{
|
||||
concurrency: 10,
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeMessage(
|
||||
ctx: TcContext<{
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
converseId: string;
|
||||
messageId: string;
|
||||
}>
|
||||
) {
|
||||
const {
|
||||
userId = ctx.meta.userId,
|
||||
groupId,
|
||||
converseId,
|
||||
messageId,
|
||||
} = ctx.params;
|
||||
|
||||
await this.adapter.model.remove({
|
||||
userId,
|
||||
type: 'message',
|
||||
payload: {
|
||||
groupId,
|
||||
converseId,
|
||||
messageId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.notifyUsersInboxUpdate(ctx, [userId]); // not good, 后面最好修改为发送删除的项而不是所有
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收件箱中所有内容
|
||||
*/
|
||||
async all(ctx: TcContext<{}>) {
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const list = await this.adapter.model.find({
|
||||
userId,
|
||||
});
|
||||
|
||||
return await this.transformDocuments(ctx, {}, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记收件箱内容已读
|
||||
*/
|
||||
async ack(ctx: TcContext<{ inboxItemIds: string[] }>) {
|
||||
const inboxItemIds = ctx.params.inboxItemIds;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
await this.adapter.model.updateMany(
|
||||
{
|
||||
_id: {
|
||||
$in: [...inboxItemIds],
|
||||
},
|
||||
userId,
|
||||
},
|
||||
{
|
||||
readed: true,
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有的收件箱内容
|
||||
*/
|
||||
async clear(ctx: TcContext) {
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
await this.adapter.model.deleteMany({
|
||||
userId,
|
||||
});
|
||||
|
||||
await this.notifyUsersInboxUpdate(ctx, [userId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知用户收件箱追加了新的内容
|
||||
*/
|
||||
private async notifyUsersInboxAppend(
|
||||
ctx: TcPureContext,
|
||||
userIds: string[],
|
||||
data: any
|
||||
): Promise<void> {
|
||||
await this.listcastNotify(ctx, userIds, 'append', { ...data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知用户收件箱有新的内容
|
||||
*/
|
||||
private async notifyUsersInboxUpdate(
|
||||
ctx: TcPureContext,
|
||||
userIds: string[]
|
||||
): Promise<void> {
|
||||
await this.listcastNotify(ctx, userIds, 'updated', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 向微服务通知有新的内容产生
|
||||
*/
|
||||
private async emitInboxAppendEvent(
|
||||
ctx: TcPureContext,
|
||||
inboxItem: InboxStruct
|
||||
) {
|
||||
await ctx.emit('chat.inbox.append', inboxItem);
|
||||
}
|
||||
}
|
||||
|
||||
export default InboxService;
|
||||
624
server/services/core/chat/message.service.ts
Normal file
624
server/services/core/chat/message.service.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import moment from 'moment';
|
||||
import { Types } from 'mongoose';
|
||||
import type {
|
||||
MessageDocument,
|
||||
MessageModel,
|
||||
} from '../../../models/chat/message';
|
||||
import {
|
||||
TcService,
|
||||
TcDbService,
|
||||
GroupBaseInfo,
|
||||
TcContext,
|
||||
DataNotFoundError,
|
||||
NoPermissionError,
|
||||
call,
|
||||
PERMISSION,
|
||||
NotFoundError,
|
||||
SYSTEM_USERID,
|
||||
} from 'tailchat-server-sdk';
|
||||
import type { Group } from '../../../models/group/group';
|
||||
import { isValidStr } from '../../../lib/utils';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface MessageService
|
||||
extends TcService,
|
||||
TcDbService<MessageDocument, MessageModel> {}
|
||||
class MessageService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'chat.message';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/chat/message').default);
|
||||
|
||||
this.registerAction('fetchConverseMessage', this.fetchConverseMessage, {
|
||||
params: {
|
||||
converseId: 'string',
|
||||
startId: { type: 'string', optional: true },
|
||||
},
|
||||
});
|
||||
this.registerAction('fetchNearbyMessage', this.fetchNearbyMessage, {
|
||||
params: {
|
||||
groupId: { type: 'string', optional: true },
|
||||
converseId: 'string',
|
||||
messageId: 'string',
|
||||
num: { type: 'number', optional: true },
|
||||
},
|
||||
});
|
||||
this.registerAction('sendMessage', this.sendMessage, {
|
||||
params: {
|
||||
converseId: 'string',
|
||||
groupId: [{ type: 'string', optional: true }],
|
||||
content: 'string',
|
||||
plain: { type: 'string', optional: true },
|
||||
meta: { type: 'any', optional: true },
|
||||
},
|
||||
});
|
||||
this.registerAction('recallMessage', this.recallMessage, {
|
||||
params: {
|
||||
messageId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('getMessage', this.getMessage, {
|
||||
params: {
|
||||
messageId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('deleteMessage', this.deleteMessage, {
|
||||
params: {
|
||||
messageId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('searchMessage', this.searchMessage, {
|
||||
params: {
|
||||
groupId: { type: 'string', optional: true },
|
||||
converseId: 'string',
|
||||
text: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction(
|
||||
'fetchConverseLastMessages',
|
||||
this.fetchConverseLastMessages,
|
||||
{
|
||||
params: {
|
||||
converseIds: 'array',
|
||||
},
|
||||
}
|
||||
);
|
||||
this.registerAction('addReaction', this.addReaction, {
|
||||
params: {
|
||||
messageId: 'string',
|
||||
emoji: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('removeReaction', this.removeReaction, {
|
||||
params: {
|
||||
messageId: 'string',
|
||||
emoji: 'string',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话消息
|
||||
*/
|
||||
async fetchConverseMessage(
|
||||
ctx: TcContext<{
|
||||
converseId: string;
|
||||
startId?: string;
|
||||
}>
|
||||
) {
|
||||
const { converseId, startId } = ctx.params;
|
||||
const docs = await this.adapter.model.fetchConverseMessage(
|
||||
converseId,
|
||||
startId ?? null
|
||||
);
|
||||
|
||||
return this.transformDocuments(ctx, {}, docs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一条消息附近的消息
|
||||
* 以会话为准
|
||||
*
|
||||
* 额外需要converseId是为了防止暴力查找
|
||||
*/
|
||||
async fetchNearbyMessage(
|
||||
ctx: TcContext<{
|
||||
groupId?: string;
|
||||
converseId: string;
|
||||
messageId: string;
|
||||
num?: number;
|
||||
}>
|
||||
) {
|
||||
const { groupId, converseId, messageId, num = 5 } = ctx.params;
|
||||
const { t } = ctx.meta;
|
||||
|
||||
// 鉴权是否能获取到会话内容
|
||||
await this.checkConversePermission(ctx, converseId, groupId);
|
||||
|
||||
const message = await this.adapter.model
|
||||
.findOne({
|
||||
_id: new Types.ObjectId(messageId),
|
||||
converseId: new Types.ObjectId(converseId),
|
||||
})
|
||||
.limit(1)
|
||||
.exec();
|
||||
|
||||
if (!message) {
|
||||
throw new DataNotFoundError(t('没有找到消息'));
|
||||
}
|
||||
|
||||
const [prev, next] = await Promise.all([
|
||||
this.adapter.model
|
||||
.find({
|
||||
_id: {
|
||||
$lt: new Types.ObjectId(messageId),
|
||||
},
|
||||
converseId: new Types.ObjectId(converseId),
|
||||
})
|
||||
.sort({ _id: -1 })
|
||||
.limit(num)
|
||||
.exec()
|
||||
.then((arr) => arr.reverse()),
|
||||
this.adapter.model
|
||||
.find({
|
||||
_id: {
|
||||
$gt: new Types.ObjectId(messageId),
|
||||
},
|
||||
converseId: new Types.ObjectId(converseId),
|
||||
})
|
||||
.sort({ _id: 1 })
|
||||
.limit(num)
|
||||
.exec(),
|
||||
]);
|
||||
|
||||
console.log({ prev, next });
|
||||
|
||||
return this.transformDocuments(ctx, {}, [...prev, message, ...next]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送普通消息
|
||||
*/
|
||||
async sendMessage(
|
||||
ctx: TcContext<{
|
||||
converseId: string;
|
||||
groupId?: string;
|
||||
content: string;
|
||||
plain?: string;
|
||||
meta?: object;
|
||||
}>
|
||||
) {
|
||||
const { converseId, groupId, content, plain, meta } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
const t = ctx.meta.t;
|
||||
const isGroupMessage = isValidStr(groupId);
|
||||
|
||||
/**
|
||||
* 鉴权
|
||||
*/
|
||||
await this.checkConversePermission(ctx, converseId, groupId); // 鉴权是否能获取到会话内容
|
||||
if (isGroupMessage) {
|
||||
// 是群组消息, 鉴权是否禁言
|
||||
const groupInfo = await call(ctx).getGroupInfo(groupId);
|
||||
const member = groupInfo.members.find((m) => String(m.userId) === userId);
|
||||
if (member) {
|
||||
// 因为有机器人,所以如果没有在成员列表中找到不报错
|
||||
if (new Date(member.muteUntil).valueOf() > new Date().valueOf()) {
|
||||
throw new Error(t('您因为被禁言无法发送消息'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = await this.adapter.insert({
|
||||
converseId: new Types.ObjectId(converseId),
|
||||
groupId:
|
||||
typeof groupId === 'string' ? new Types.ObjectId(groupId) : undefined,
|
||||
author: new Types.ObjectId(userId),
|
||||
content,
|
||||
meta,
|
||||
});
|
||||
|
||||
const json = await this.transformDocuments(ctx, {}, message);
|
||||
|
||||
if (isGroupMessage) {
|
||||
this.roomcastNotify(ctx, converseId, 'add', json);
|
||||
} else {
|
||||
// 如果是私信的话需要基于用户去推送
|
||||
// 因为用户可能不订阅消息(删除了dmlist)
|
||||
const converseInfo = await call(ctx).getConverseInfo(converseId);
|
||||
if (converseInfo) {
|
||||
const converseMemberIds = converseInfo.members.map((m) => String(m));
|
||||
|
||||
call(ctx)
|
||||
.isUserOnline(converseMemberIds)
|
||||
.then((onlineList) => {
|
||||
_.zip(converseMemberIds, onlineList).forEach(
|
||||
([memberId, isOnline]) => {
|
||||
if (isOnline) {
|
||||
// 用户在线,则直接推送,通过客户端来创建会话
|
||||
this.unicastNotify(ctx, memberId, 'add', json);
|
||||
} else {
|
||||
// 用户离线,确保追加到会话中
|
||||
ctx.call(
|
||||
'user.dmlist.addConverse',
|
||||
{ converseId },
|
||||
{
|
||||
meta: {
|
||||
userId: memberId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.emit('chat.message.updateMessage', {
|
||||
type: 'add',
|
||||
groupId: groupId ? String(groupId) : undefined,
|
||||
converseId: String(converseId),
|
||||
messageId: String(message._id),
|
||||
author: userId,
|
||||
content,
|
||||
plain,
|
||||
meta: meta ?? {},
|
||||
});
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回消息
|
||||
*/
|
||||
async recallMessage(ctx: TcContext<{ messageId: string }>) {
|
||||
const { messageId } = ctx.params;
|
||||
const { t, userId } = ctx.meta;
|
||||
|
||||
const message = await this.adapter.model.findById(messageId);
|
||||
if (!message) {
|
||||
throw new DataNotFoundError(t('该消息未找到'));
|
||||
}
|
||||
|
||||
if (message.hasRecall === true) {
|
||||
throw new Error(t('该消息已被撤回'));
|
||||
}
|
||||
|
||||
// 消息撤回限时
|
||||
if (
|
||||
moment().valueOf() - moment(message.createdAt).valueOf() >
|
||||
15 * 60 * 1000
|
||||
) {
|
||||
throw new Error(t('无法撤回 {{minutes}} 分钟前的消息', { minutes: 15 }));
|
||||
}
|
||||
|
||||
let allowToRecall = false;
|
||||
|
||||
//#region 撤回权限检查
|
||||
const groupId = message.groupId;
|
||||
if (groupId) {
|
||||
// 是一条群组信息
|
||||
const group: GroupBaseInfo = await ctx.call('group.getGroupBasicInfo', {
|
||||
groupId: String(groupId),
|
||||
});
|
||||
if (String(group.owner) === userId) {
|
||||
allowToRecall = true; // 是管理员 允许修改
|
||||
}
|
||||
}
|
||||
|
||||
if (String(message.author) === String(userId)) {
|
||||
// 撤回者是消息所有者
|
||||
allowToRecall = true;
|
||||
}
|
||||
|
||||
if (allowToRecall === false) {
|
||||
throw new NoPermissionError(t('撤回失败, 没有权限'));
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const converseId = String(message.converseId);
|
||||
message.hasRecall = true;
|
||||
await message.save();
|
||||
|
||||
const json = await this.transformDocuments(ctx, {}, message);
|
||||
|
||||
this.roomcastNotify(ctx, converseId, 'update', json);
|
||||
ctx.emit('chat.message.updateMessage', {
|
||||
type: 'recall',
|
||||
groupId: groupId ? String(groupId) : undefined,
|
||||
converseId: String(converseId),
|
||||
messageId: String(message._id),
|
||||
meta: message.meta ?? {},
|
||||
});
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息
|
||||
*/
|
||||
async getMessage(ctx: TcContext<{ messageId: string }>) {
|
||||
const { messageId } = ctx.params;
|
||||
const { t, userId } = ctx.meta;
|
||||
const message = await this.adapter.model.findById(messageId);
|
||||
if (!message) {
|
||||
throw new DataNotFoundError(t('该消息未找到'));
|
||||
}
|
||||
const converseId = String(message.converseId);
|
||||
const groupId = message.groupId;
|
||||
// 鉴权
|
||||
if (!groupId) {
|
||||
// 私人会话
|
||||
const converseInfo = await call(ctx).getConverseInfo(converseId);
|
||||
if (!converseInfo.members.map((m) => String(m)).includes(userId)) {
|
||||
throw new NoPermissionError(t('没有当前会话权限'));
|
||||
}
|
||||
} else {
|
||||
// 群组会话
|
||||
const groupInfo = await call(ctx).getGroupInfo(String(groupId));
|
||||
if (!groupInfo.members.map((m) => m.userId).includes(userId)) {
|
||||
throw new NoPermissionError(t('没有当前会话权限'));
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* 仅支持群组
|
||||
*/
|
||||
async deleteMessage(ctx: TcContext<{ messageId: string }>) {
|
||||
const { messageId } = ctx.params;
|
||||
const { t, userId } = ctx.meta;
|
||||
|
||||
const message = await this.adapter.model.findById(messageId);
|
||||
if (!message) {
|
||||
throw new DataNotFoundError(t('该消息未找到'));
|
||||
}
|
||||
|
||||
const converseId = String(message.converseId);
|
||||
const groupId = message.groupId;
|
||||
if (!groupId) {
|
||||
// 私人会话
|
||||
if (userId !== SYSTEM_USERID) {
|
||||
// 如果是私人发起的, 则直接抛出异常
|
||||
throw new Error(t('无法删除私人信息'));
|
||||
}
|
||||
} else {
|
||||
// 群组会话, 进行权限校验
|
||||
const [hasPermission] = await call(ctx).checkUserPermissions(
|
||||
String(groupId),
|
||||
userId,
|
||||
[PERMISSION.core.deleteMessage]
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new NoPermissionError(t('没有删除权限')); // 仅管理员允许删除
|
||||
}
|
||||
}
|
||||
|
||||
await this.adapter.removeById(messageId); // TODO: 考虑是否要改为软删除
|
||||
|
||||
this.roomcastNotify(ctx, converseId, 'delete', { converseId, messageId });
|
||||
ctx.emit('chat.message.updateMessage', {
|
||||
type: 'delete',
|
||||
groupId: groupId ? String(groupId) : undefined,
|
||||
converseId: String(converseId),
|
||||
messageId: String(message._id),
|
||||
meta: message.meta ?? {},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索消息
|
||||
*/
|
||||
async searchMessage(
|
||||
ctx: TcContext<{ groupId?: string; converseId: string; text: string }>
|
||||
) {
|
||||
const { groupId, converseId, text } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
if (groupId) {
|
||||
const groupInfo = await call(ctx).getGroupInfo(groupId);
|
||||
if (!groupInfo.members.map((m) => m.userId).includes(userId)) {
|
||||
throw new Error(t('不是群组成员无法搜索消息'));
|
||||
}
|
||||
}
|
||||
|
||||
const messages = this.adapter.model
|
||||
.find({
|
||||
groupId: groupId ?? null,
|
||||
converseId,
|
||||
content: {
|
||||
$regex: text,
|
||||
},
|
||||
author: {
|
||||
$not: {
|
||||
$eq: SYSTEM_USERID,
|
||||
},
|
||||
},
|
||||
})
|
||||
.sort({ _id: -1 })
|
||||
.limit(10)
|
||||
.maxTimeMS(5 * 1000); // 超过5s的查询直接放弃
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于会话id获取会话最后一条消息的id
|
||||
*/
|
||||
async fetchConverseLastMessages(ctx: TcContext<{ converseIds: string[] }>) {
|
||||
const { converseIds } = ctx.params;
|
||||
|
||||
// 这里使用了多个请求,但是通过limit=1会将查询范围降低到最低
|
||||
// 这种方式会比用聚合操作实际上更加节省资源
|
||||
const list = await Promise.all(
|
||||
converseIds.map((id) => {
|
||||
return this.adapter.model
|
||||
.findOne(
|
||||
{
|
||||
converseId: new Types.ObjectId(id),
|
||||
},
|
||||
{
|
||||
_id: 1,
|
||||
converseId: 1,
|
||||
}
|
||||
)
|
||||
.sort({
|
||||
_id: -1,
|
||||
})
|
||||
.limit(1)
|
||||
.exec();
|
||||
})
|
||||
);
|
||||
|
||||
return list.map((item) =>
|
||||
item
|
||||
? {
|
||||
converseId: String(item.converseId),
|
||||
lastMessageId: String(item._id),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
async addReaction(
|
||||
ctx: TcContext<{
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
}>
|
||||
) {
|
||||
const { messageId, emoji } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const message = await this.adapter.model.findById(messageId);
|
||||
|
||||
const appendReaction = {
|
||||
name: emoji,
|
||||
author: new Types.ObjectId(userId),
|
||||
};
|
||||
|
||||
await this.adapter.model.updateOne(
|
||||
{
|
||||
_id: messageId,
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
reactions: {
|
||||
...appendReaction,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const converseId = String(message.converseId);
|
||||
this.roomcastNotify(ctx, converseId, 'addReaction', {
|
||||
converseId,
|
||||
messageId,
|
||||
reaction: {
|
||||
...appendReaction,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeReaction(
|
||||
ctx: TcContext<{
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
}>
|
||||
) {
|
||||
const { messageId, emoji } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const message = await this.adapter.model.findById(messageId);
|
||||
|
||||
const removedReaction = {
|
||||
name: emoji,
|
||||
author: new Types.ObjectId(userId),
|
||||
};
|
||||
|
||||
await this.adapter.model.updateOne(
|
||||
{
|
||||
_id: messageId,
|
||||
},
|
||||
{
|
||||
$pull: {
|
||||
reactions: {
|
||||
...removedReaction,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const converseId = String(message.converseId);
|
||||
this.roomcastNotify(ctx, converseId, 'removeReaction', {
|
||||
converseId,
|
||||
messageId,
|
||||
reaction: {
|
||||
...removedReaction,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验会话权限,如果没有抛出异常则视为正常
|
||||
*/
|
||||
private async checkConversePermission(
|
||||
ctx: TcContext,
|
||||
converseId: string,
|
||||
groupId?: string
|
||||
) {
|
||||
const userId = ctx.meta.userId;
|
||||
const t = ctx.meta.t;
|
||||
if (userId === SYSTEM_USERID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfo = await call(ctx).getUserInfo(userId); // TODO: 可以通过在默认的meta信息中追加用户类型来减少一次请求来优化
|
||||
if (userInfo.type === 'pluginBot') {
|
||||
// 如果是插件机器人则拥有所有权限(开放平台机器人需要添加到群组才有会话权限)
|
||||
return;
|
||||
}
|
||||
|
||||
// 鉴权是否能获取到会话内容
|
||||
if (groupId) {
|
||||
// 是群组
|
||||
const group = await call(ctx).getGroupInfo(groupId);
|
||||
if (group.members.findIndex((m) => String(m.userId) === userId) === -1) {
|
||||
// 不存在该用户
|
||||
throw new NoPermissionError(t('没有当前会话权限'));
|
||||
}
|
||||
} else {
|
||||
// 是普通会话
|
||||
const converse = await ctx.call<
|
||||
any,
|
||||
{
|
||||
converseId: string;
|
||||
}
|
||||
>('chat.converse.findConverseInfo', {
|
||||
converseId,
|
||||
});
|
||||
|
||||
if (!converse) {
|
||||
throw new NotFoundError(t('没有找到会话信息'));
|
||||
}
|
||||
const memebers = converse.members ?? [];
|
||||
if (memebers.findIndex((member) => String(member) === userId) === -1) {
|
||||
throw new NoPermissionError(t('没有当前会话权限'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageService;
|
||||
142
server/services/core/config.service.ts
Normal file
142
server/services/core/config.service.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
TcService,
|
||||
TcPureContext,
|
||||
config,
|
||||
TcDbService,
|
||||
TcContext,
|
||||
} from 'tailchat-server-sdk';
|
||||
import type { ConfigDocument, ConfigModel } from '../../models/config';
|
||||
|
||||
/**
|
||||
* 配置服务器
|
||||
*/
|
||||
interface ConfigService
|
||||
extends TcService,
|
||||
TcDbService<ConfigDocument, ConfigModel> {}
|
||||
class ConfigService extends TcService {
|
||||
config = {}; // 自管理的配置项,globalConfig是同步过来的
|
||||
|
||||
get serviceName(): string {
|
||||
return 'config';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../models/config').default);
|
||||
this.registerAction('client', this.client, {
|
||||
cache: {
|
||||
keys: [],
|
||||
ttl: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
});
|
||||
this.registerAction('setClientConfig', this.setClientConfig, {
|
||||
params: {
|
||||
key: 'string',
|
||||
value: 'any',
|
||||
},
|
||||
visibility: 'public',
|
||||
});
|
||||
this.registerAction('all', this.all, {
|
||||
visibility: 'public',
|
||||
});
|
||||
this.registerAction('get', this.get, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
key: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('set', this.set, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
key: 'string',
|
||||
value: 'any',
|
||||
},
|
||||
});
|
||||
this.registerAction('addToSet', this.addToSet, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
key: 'string',
|
||||
value: 'any',
|
||||
},
|
||||
});
|
||||
|
||||
this.registerAuthWhitelist(['/client']);
|
||||
if (config.env === 'development') {
|
||||
this.cleanActionCache('client'); // 初始化时清理缓存
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局配置
|
||||
*
|
||||
* 用于提供给前端使用
|
||||
*
|
||||
* NOTICE: 返回内容比较简单,因此不建议增加缓存
|
||||
*/
|
||||
async client(ctx: TcPureContext) {
|
||||
const persistConfig = await this.adapter.model.getAllClientPersistConfig();
|
||||
|
||||
return {
|
||||
tianji: config.tianji,
|
||||
uploadFileLimit: config.storage.limit,
|
||||
emailVerification: config.emailVerification,
|
||||
disableMsgpack: config.feature.disableMsgpack,
|
||||
disableUserRegister: config.feature.disableUserRegister,
|
||||
disableGuestLogin: config.feature.disableGuestLogin,
|
||||
disableCreateGroup: config.feature.disableCreateGroup,
|
||||
disablePluginStore: config.feature.disablePluginStore,
|
||||
disableAddFriend: config.feature.disableAddFriend,
|
||||
disableTelemetry: config.feature.disableTelemetry,
|
||||
...persistConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* set client config in tailchat network
|
||||
*
|
||||
* usually call from admin
|
||||
*/
|
||||
async setClientConfig(
|
||||
ctx: TcContext<{
|
||||
key: string;
|
||||
value: any;
|
||||
}>
|
||||
) {
|
||||
const { key, value } = ctx.params;
|
||||
const newConfig = await this.adapter.model.setClientPersistConfig(
|
||||
key,
|
||||
value
|
||||
);
|
||||
await this.cleanActionCache('client', []);
|
||||
this.broadcastNotify(ctx, 'updateClientConfig', newConfig);
|
||||
}
|
||||
|
||||
async all(ctx: TcContext) {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async get(ctx: TcContext<{ key: string }>) {
|
||||
return this.config[ctx.params.key] ?? null;
|
||||
}
|
||||
|
||||
async set(ctx: TcContext<{ key: string; value: any }>) {
|
||||
const { key, value } = ctx.params;
|
||||
|
||||
_.set(this.config, key, value);
|
||||
await this.broker.broadcast('config.updated', { config: this.config });
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到设置但不重复
|
||||
*/
|
||||
async addToSet(ctx: TcContext<{ key: string; value: any }>) {
|
||||
const { key, value } = ctx.params;
|
||||
|
||||
const originConfig = _.get(this.config, key) ?? [];
|
||||
_.set(this.config, key, _.uniq([...originConfig, value]));
|
||||
|
||||
await this.broker.broadcast('config.updated', { config: this.config });
|
||||
}
|
||||
}
|
||||
|
||||
export default ConfigService;
|
||||
401
server/services/core/file.service.ts
Normal file
401
server/services/core/file.service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
TcService,
|
||||
PureContext,
|
||||
TcContext,
|
||||
buildUploadUrl,
|
||||
config,
|
||||
TcDbService,
|
||||
NoPermissionError,
|
||||
TcMinioService,
|
||||
} from 'tailchat-server-sdk';
|
||||
import _ from 'lodash';
|
||||
import mime from 'mime';
|
||||
import type { BucketItemStat, Client as MinioClient } from 'minio';
|
||||
import { isValidStaticAssetsUrl, isValidStr } from '../../lib/utils';
|
||||
import path from 'node:path';
|
||||
import type { FileDocument, FileModel } from '../../models/file';
|
||||
import { Types } from 'mongoose';
|
||||
import got from 'got';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
interface FileService extends TcService, TcDbService<FileDocument, FileModel> {}
|
||||
class FileService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
get minioClient(): MinioClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
get bucketName(): string {
|
||||
return config.storage.bucketName;
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../models/file').default);
|
||||
this.registerMixin(TcMinioService);
|
||||
const minioUrl = config.storage.minioUrl;
|
||||
const [endPoint, port] = minioUrl.split(':');
|
||||
|
||||
// https://github.com/designtesbrot/moleculer-minio#settings
|
||||
this.registerSetting('endPoint', endPoint);
|
||||
this.registerSetting('port', Number(port));
|
||||
this.registerSetting('useSSL', config.storage.ssl);
|
||||
this.registerSetting('accessKey', config.storage.user);
|
||||
this.registerSetting('secretKey', config.storage.pass);
|
||||
this.registerSetting('pathStyle', config.storage.pathStyle);
|
||||
|
||||
this.registerAction('save', this.save);
|
||||
this.registerAction('saveFileWithUrl', this.saveFileWithUrl, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
fileUrl: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('get', this.get, {
|
||||
params: {
|
||||
objectName: 'string',
|
||||
},
|
||||
disableSocket: true,
|
||||
});
|
||||
this.registerAction('stat', this.stat, {
|
||||
params: {
|
||||
objectName: 'string',
|
||||
},
|
||||
disableSocket: true,
|
||||
});
|
||||
this.registerAction('delete', this.delete, {
|
||||
params: {
|
||||
objectName: 'string',
|
||||
},
|
||||
disableSocket: true,
|
||||
visibility: 'public',
|
||||
});
|
||||
}
|
||||
|
||||
async onInited() {
|
||||
// TODO: 看看有没有办法用一个ctx包起来
|
||||
// Services Available
|
||||
if (config.feature.disableFileCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExists = await this.actions['bucketExists'](
|
||||
{
|
||||
bucketName: this.bucketName,
|
||||
},
|
||||
{
|
||||
timeout: 20000, // 20s
|
||||
}
|
||||
);
|
||||
if (isExists === false) {
|
||||
// bucket不存在,创建新的
|
||||
this.logger.info(
|
||||
'[File]',
|
||||
'Bucket 不存在, 创建新的Bucket',
|
||||
this.bucketName
|
||||
);
|
||||
await this.actions['makeBucket']({
|
||||
bucketName: this.bucketName,
|
||||
});
|
||||
}
|
||||
|
||||
const buckets = await this.actions['listBuckets']();
|
||||
this.logger.info(`[File] MinioInfo: | buckets: ${JSON.stringify(buckets)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件流存储到本地
|
||||
*/
|
||||
async save(
|
||||
ctx: TcContext<
|
||||
{},
|
||||
{
|
||||
$multipart: any;
|
||||
$params: any;
|
||||
filename: any;
|
||||
}
|
||||
>
|
||||
) {
|
||||
const t = ctx.meta.t;
|
||||
this.logger.info('Received upload $params:', ctx.meta.$params);
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const userId = ctx.meta.userId;
|
||||
this.logger.info('Received upload meta:', ctx.meta);
|
||||
|
||||
if (!isValidStr(userId)) {
|
||||
throw new NoPermissionError(t('用户无上传权限'));
|
||||
}
|
||||
|
||||
const originFilename = String(ctx.meta.filename);
|
||||
const usage = _.get(ctx, 'meta.$multipart.usage', 'unknown');
|
||||
|
||||
const stream = ctx.params as NodeJS.ReadableStream;
|
||||
(stream as any).on('error', (err) => {
|
||||
// 这里是文件传输错误处理
|
||||
// 比如文件过大
|
||||
this.logger.error('File error received', err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
try {
|
||||
const { etag, objectName, url } = await this.saveFileStream(
|
||||
ctx,
|
||||
originFilename,
|
||||
stream,
|
||||
usage
|
||||
);
|
||||
|
||||
resolve({
|
||||
etag,
|
||||
path: `${this.bucketName}/${objectName}`,
|
||||
url,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过url存储文件
|
||||
* 仅允许内部调用
|
||||
*
|
||||
* NOTICE: 这里可能会有一个问题,就是可能会出现同一张图片被不同人存储多遍
|
||||
* 需要优化
|
||||
* @param fileUrl
|
||||
*/
|
||||
async saveFileWithUrl(
|
||||
ctx: TcContext<{
|
||||
fileUrl: string;
|
||||
}>
|
||||
) {
|
||||
const fileUrl = ctx.params.fileUrl;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
if (!isValidStaticAssetsUrl(fileUrl)) {
|
||||
throw new Error(t('文件地址不是一个合法的资源地址'));
|
||||
}
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const req = got.stream(fileUrl);
|
||||
const stream = Readable.from(req);
|
||||
stream.on('error', (err: Error) => {
|
||||
// 这里是文件传输错误处理
|
||||
// 比如文件过大
|
||||
this.logger.error('File error received', err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
try {
|
||||
const filename = _.last(fileUrl.split('/'));
|
||||
const { etag, objectName, url } = await this.saveFileStream(
|
||||
ctx,
|
||||
filename,
|
||||
stream
|
||||
);
|
||||
|
||||
resolve({
|
||||
etag,
|
||||
path: `${this.bucketName}/${objectName}`,
|
||||
url,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件流
|
||||
*/
|
||||
async saveFileStream(
|
||||
ctx: TcContext,
|
||||
filename: string,
|
||||
fileStream: NodeJS.ReadableStream,
|
||||
usage = 'unknown'
|
||||
): Promise<{ etag: string; url: string; objectName: string }> {
|
||||
const span = ctx.startSpan('file.saveFileStream');
|
||||
const ext = path.extname(filename);
|
||||
|
||||
// 临时仓库
|
||||
const tmpObjectName = `tmp/${this.randomName()}${ext}`;
|
||||
|
||||
const { etag } = await this.actions['putObject'](fileStream, {
|
||||
meta: {
|
||||
bucketName: this.bucketName,
|
||||
objectName: tmpObjectName,
|
||||
metaData: {
|
||||
'content-type': mime.getType(ext),
|
||||
},
|
||||
},
|
||||
parentCtx: ctx,
|
||||
});
|
||||
|
||||
const { url, objectName } = await this.persistFile(
|
||||
ctx,
|
||||
tmpObjectName,
|
||||
etag,
|
||||
ext,
|
||||
usage
|
||||
);
|
||||
|
||||
span.finish();
|
||||
|
||||
return {
|
||||
etag,
|
||||
url,
|
||||
objectName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化存储文件
|
||||
*/
|
||||
async persistFile(
|
||||
ctx: TcContext,
|
||||
tmpObjectName: string,
|
||||
etag: string,
|
||||
ext: string,
|
||||
usage = 'unknown'
|
||||
): Promise<{
|
||||
url: string;
|
||||
objectName: string;
|
||||
}> {
|
||||
const span = ctx.startSpan('file.persistFile');
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
// 存储在上传者自己的子目录
|
||||
const objectName = `files/${userId}/${etag}${ext}`;
|
||||
|
||||
try {
|
||||
await this.actions['copyObject'](
|
||||
{
|
||||
bucketName: this.bucketName,
|
||||
objectName,
|
||||
sourceObject: `/${this.bucketName}/${tmpObjectName}`, // NOTICE: 此处要填入带bucketName的完成路径
|
||||
conditions: {
|
||||
matchETag: etag,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentCtx: ctx,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
this.minioClient.removeObject(this.bucketName, tmpObjectName);
|
||||
}
|
||||
|
||||
const url = buildUploadUrl(objectName);
|
||||
|
||||
// 异步执行, 将其存入数据库
|
||||
this.minioClient
|
||||
.statObject(this.bucketName, objectName)
|
||||
.then((stat) =>
|
||||
this.adapter.model.updateOne(
|
||||
{
|
||||
bucketName: this.bucketName,
|
||||
objectName,
|
||||
},
|
||||
{
|
||||
etag,
|
||||
userId: new Types.ObjectId(userId),
|
||||
url,
|
||||
size: stat.size,
|
||||
metaData: stat.metaData,
|
||||
usage,
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
.catch((err) => {
|
||||
this.logger.error(`持久化到数据库失败: ${objectName}`, err);
|
||||
});
|
||||
|
||||
span.finish();
|
||||
|
||||
return {
|
||||
url,
|
||||
objectName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端的信息
|
||||
*/
|
||||
async get(
|
||||
ctx: PureContext<{
|
||||
objectName: string;
|
||||
}>
|
||||
) {
|
||||
const objectName = ctx.params.objectName;
|
||||
|
||||
const stream = await this.minioClient.getObject(
|
||||
this.bucketName,
|
||||
objectName
|
||||
);
|
||||
|
||||
this.adapter.model
|
||||
.updateOne(
|
||||
{
|
||||
bucketName: this.bucketName,
|
||||
objectName,
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
views: 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端的信息
|
||||
*/
|
||||
async stat(
|
||||
ctx: PureContext<{
|
||||
objectName: string;
|
||||
}>
|
||||
): Promise<BucketItemStat> {
|
||||
const objectName = ctx.params.objectName;
|
||||
|
||||
const stat = await this.minioClient.statObject(this.bucketName, objectName);
|
||||
|
||||
return stat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async delete(
|
||||
ctx: TcContext<{
|
||||
objectName: string;
|
||||
}>
|
||||
) {
|
||||
const objectName = ctx.params.objectName;
|
||||
|
||||
try {
|
||||
// 先删文件再删记录,确保文件被删除
|
||||
await this.minioClient.removeObject(this.bucketName, objectName);
|
||||
await this.adapter.model.deleteMany({
|
||||
bucketName: this.bucketName,
|
||||
objectName,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn('Delete file error:', objectName, err);
|
||||
}
|
||||
}
|
||||
|
||||
private randomName() {
|
||||
return `unnamed_${this.broker.nodeID}_${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default FileService;
|
||||
404
server/services/core/gateway.service.ts
Normal file
404
server/services/core/gateway.service.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import _ from 'lodash';
|
||||
import { TcSocketIOService } from '../../mixins/socketio.mixin';
|
||||
import {
|
||||
TcService,
|
||||
UserJWTPayload,
|
||||
config,
|
||||
t,
|
||||
parseLanguageFromHead,
|
||||
builtinAuthWhitelist,
|
||||
PureContext,
|
||||
ApiGatewayMixin,
|
||||
ApiGatewayErrors,
|
||||
} from 'tailchat-server-sdk';
|
||||
import { TcHealth } from '../../mixins/health.mixin';
|
||||
import type { Readable } from 'stream';
|
||||
import { checkPathMatch } from '../../lib/utils';
|
||||
import serve from 'serve-static';
|
||||
import accepts from 'accepts';
|
||||
import send from 'send';
|
||||
import path from 'path';
|
||||
import mime from 'mime';
|
||||
|
||||
export default class ApiService extends TcService {
|
||||
authWhitelist = [];
|
||||
|
||||
get serviceName() {
|
||||
return 'gateway';
|
||||
}
|
||||
|
||||
onInit() {
|
||||
this.registerMixin(ApiGatewayMixin);
|
||||
this.registerMixin(
|
||||
TcSocketIOService({
|
||||
userAuth: async (token) => {
|
||||
const user: UserJWTPayload = await this.broker.call(
|
||||
'user.resolveToken',
|
||||
{
|
||||
token,
|
||||
}
|
||||
);
|
||||
|
||||
return user;
|
||||
},
|
||||
disableMsgpack: config.feature.disableMsgpack,
|
||||
})
|
||||
);
|
||||
this.registerMixin(TcHealth());
|
||||
|
||||
// More info about settings: https://moleculer.services/docs/0.14/moleculer-web.html
|
||||
this.registerSetting('port', config.port);
|
||||
this.registerSetting('routes', this.getRoutes());
|
||||
// Do not log client side errors (does not log an error response when the error.code is 400<=X<500)
|
||||
this.registerSetting('log4XXResponses', false);
|
||||
// Logging the request parameters. Set to any log level to enable it. E.g. "info"
|
||||
this.registerSetting('logRequestParams', null);
|
||||
// Logging the response data. Set to any log level to enable it. E.g. "info"
|
||||
this.registerSetting('logResponseData', null);
|
||||
// Serve assets from "public" folder
|
||||
// this.registerSetting('assets', {
|
||||
// folder: 'public',
|
||||
// // Options to `server-static` module
|
||||
// options: {},
|
||||
// });
|
||||
this.registerSetting('cors', {
|
||||
// Configures the Access-Control-Allow-Origin CORS header.
|
||||
origin: '*',
|
||||
// Configures the Access-Control-Allow-Methods CORS header.
|
||||
methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE'],
|
||||
// Configures the Access-Control-Allow-Headers CORS header.
|
||||
allowedHeaders: ['X-Token', 'Content-Type'],
|
||||
// Configures the Access-Control-Expose-Headers CORS header.
|
||||
exposedHeaders: [],
|
||||
// Configures the Access-Control-Allow-Credentials CORS header.
|
||||
credentials: false,
|
||||
// Configures the Access-Control-Max-Age CORS header.
|
||||
maxAge: 3600,
|
||||
});
|
||||
// this.registerSetting('rateLimit', {
|
||||
// // How long to keep record of requests in memory (in milliseconds).
|
||||
// // Defaults to 60000 (1 min)
|
||||
// window: 60 * 1000,
|
||||
|
||||
// // Max number of requests during window. Defaults to 30
|
||||
// limit: 60,
|
||||
|
||||
// // Set rate limit headers to response. Defaults to false
|
||||
// headers: true,
|
||||
|
||||
// // Function used to generate keys. Defaults to:
|
||||
// key: (req) => {
|
||||
// return (
|
||||
// req.headers['x-forwarded-for'] ||
|
||||
// req.connection.remoteAddress ||
|
||||
// req.socket.remoteAddress ||
|
||||
// req.connection.socket.remoteAddress
|
||||
// );
|
||||
// },
|
||||
// });
|
||||
|
||||
this.registerMethod('authorize', this.authorize);
|
||||
|
||||
this.registerEventListener(
|
||||
'gateway.auth.addWhitelists',
|
||||
({ urls = [] }) => {
|
||||
this.logger.info('Add auth whitelist:', urls);
|
||||
this.authWhitelist.push(...urls);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getRoutes() {
|
||||
return [
|
||||
// /api
|
||||
{
|
||||
path: '/api',
|
||||
whitelist: [
|
||||
// Access to any actions in all services under "/api" URL
|
||||
'**',
|
||||
],
|
||||
// Route-level Express middlewares. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Middlewares
|
||||
use: [],
|
||||
// Enable/disable parameter merging method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Disable-merging
|
||||
mergeParams: true,
|
||||
|
||||
// Enable authentication. Implement the logic into `authenticate` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authentication
|
||||
authentication: false,
|
||||
|
||||
// Enable authorization. Implement the logic into `authorize` method. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Authorization
|
||||
authorization: true,
|
||||
|
||||
// The auto-alias feature allows you to declare your route alias directly in your services.
|
||||
// The gateway will dynamically build the full routes from service schema.
|
||||
autoAliases: true,
|
||||
|
||||
aliases: {},
|
||||
/**
|
||||
* Before call hook. You can check the request.
|
||||
* @param {PureContext} ctx
|
||||
* @param {Object} route
|
||||
* @param {IncomingMessage} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {Object} data*/
|
||||
onBeforeCall(
|
||||
ctx: PureContext<any, { userAgent: string; language: string }>,
|
||||
route: object,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
) {
|
||||
// Set request headers to context meta
|
||||
ctx.meta.userAgent = req.headers['user-agent'];
|
||||
ctx.meta.language = parseLanguageFromHead(
|
||||
req.headers['accept-language']
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* After call hook. You can modify the data.
|
||||
* @param {PureContext} ctx
|
||||
* @param {Object} route
|
||||
* @param {IncomingMessage} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {Object} data
|
||||
*
|
||||
*/
|
||||
onAfterCall(
|
||||
ctx: PureContext,
|
||||
route: object,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
data: object
|
||||
) {
|
||||
// Async function which return with Promise
|
||||
res.setHeader('X-Node-ID', ctx.nodeID);
|
||||
|
||||
if (data && data['__raw']) {
|
||||
// 如果返回值有__raw, 则视为返回了html片段
|
||||
if (data['header']) {
|
||||
Object.entries(data['header']).forEach(([key, value]) => {
|
||||
res.setHeader(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
res.write(data['html'] ?? '');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
return { code: res.statusCode, data };
|
||||
},
|
||||
|
||||
// Calling options. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Calling-options
|
||||
callingOptions: {},
|
||||
|
||||
bodyParsers: {
|
||||
json: {
|
||||
strict: false,
|
||||
limit: '1MB',
|
||||
},
|
||||
urlencoded: {
|
||||
extended: true,
|
||||
limit: '1MB',
|
||||
},
|
||||
},
|
||||
|
||||
// Mapping policy setting. More info: https://moleculer.services/docs/0.14/moleculer-web.html#Mapping-policy
|
||||
mappingPolicy: 'all', // Available values: "all", "restrict"
|
||||
|
||||
// Enable/disable logging
|
||||
logging: true,
|
||||
},
|
||||
// /upload
|
||||
{
|
||||
// Reference: https://github.com/moleculerjs/moleculer-web/blob/master/examples/file/index.js
|
||||
path: '/upload',
|
||||
// You should disable body parsers
|
||||
bodyParsers: {
|
||||
json: false,
|
||||
urlencoded: false,
|
||||
},
|
||||
|
||||
authentication: false,
|
||||
authorization: true,
|
||||
|
||||
aliases: {
|
||||
// File upload from HTML form
|
||||
'POST /': {
|
||||
type: 'multipart',
|
||||
action: 'file.save',
|
||||
},
|
||||
|
||||
// File upload from AJAX or cURL
|
||||
'PUT /': {
|
||||
type: 'stream',
|
||||
action: 'file.save',
|
||||
},
|
||||
},
|
||||
|
||||
// https://github.com/mscdex/busboy#busboy-methods
|
||||
busboyConfig: {
|
||||
limits: {
|
||||
files: 1,
|
||||
fileSize: config.storage.limit,
|
||||
},
|
||||
onPartsLimit(busboy, alias, svc) {
|
||||
this.logger.info('Busboy parts limit!', busboy);
|
||||
},
|
||||
onFilesLimit(busboy, alias, svc) {
|
||||
this.logger.info('Busboy file limit!', busboy);
|
||||
},
|
||||
onFieldsLimit(busboy, alias, svc) {
|
||||
this.logger.info('Busboy fields limit!', busboy);
|
||||
},
|
||||
},
|
||||
|
||||
callOptions: {
|
||||
meta: {
|
||||
a: 5,
|
||||
},
|
||||
},
|
||||
|
||||
mappingPolicy: 'restrict',
|
||||
},
|
||||
// /health
|
||||
{
|
||||
path: '/health',
|
||||
aliases: {
|
||||
'GET /': 'gateway.health',
|
||||
},
|
||||
mappingPolicy: 'restrict',
|
||||
},
|
||||
// /static 对象存储文件代理
|
||||
{
|
||||
path: '/static',
|
||||
authentication: false,
|
||||
authorization: false,
|
||||
aliases: {
|
||||
async 'GET /:objectName+'(
|
||||
this: TcService,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse
|
||||
) {
|
||||
const objectName = _.get(req, '$params.objectName');
|
||||
|
||||
try {
|
||||
const result: Readable = await this.broker.call(
|
||||
'file.get',
|
||||
{
|
||||
objectName,
|
||||
},
|
||||
{
|
||||
parentCtx: _.get(req, '$ctx'),
|
||||
}
|
||||
);
|
||||
|
||||
const ext = path.extname(objectName);
|
||||
if (ext) {
|
||||
res.setHeader('Content-Type', mime.getType(ext));
|
||||
}
|
||||
|
||||
// 因为对象存储的对象名都是以文件内容hash存储的,因此过期时间可以设置很大
|
||||
res.setHeader('Cache-Control', 'public, max-age=315360000'); // 10 years => 60 * 60 * 24 * 365 * 10
|
||||
|
||||
result.pipe(res);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
res.write('static file not found');
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
},
|
||||
mappingPolicy: 'restrict',
|
||||
},
|
||||
// 静态文件代理
|
||||
{
|
||||
path: '/',
|
||||
authentication: false,
|
||||
authorization: false,
|
||||
use: [
|
||||
serve('public', {
|
||||
cacheControl: true,
|
||||
maxAge: '1d', // 1 day for public file, include plugins
|
||||
setHeaders(res: ServerResponse, path: string, stat: any) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*'); // 允许跨域
|
||||
},
|
||||
}),
|
||||
],
|
||||
onError(req: IncomingMessage, res: ServerResponse, err) {
|
||||
if (
|
||||
String(req.method).toLowerCase() === 'get' && // get请求
|
||||
accepts(req).types(['html']) && // 且请求html页面
|
||||
err.code === 404
|
||||
) {
|
||||
// 如果没有找到, 则返回index.html(for spa)
|
||||
this.logger.info('fallback to fe entry file');
|
||||
send(req, './public/index.html', { root: process.cwd() }).pipe(res);
|
||||
}
|
||||
},
|
||||
whitelist: [],
|
||||
autoAliases: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取鉴权白名单
|
||||
* 在白名单中的路由会被跳过
|
||||
*/
|
||||
getAuthWhitelist() {
|
||||
return _.uniq([...builtinAuthWhitelist, ...this.authWhitelist]);
|
||||
}
|
||||
|
||||
/**
|
||||
* jwt秘钥
|
||||
*/
|
||||
get jwtSecretKey() {
|
||||
return config.secret;
|
||||
}
|
||||
|
||||
async authorize(
|
||||
ctx: PureContext<{}, any>,
|
||||
route: unknown,
|
||||
req: IncomingMessage
|
||||
) {
|
||||
if (checkPathMatch(this.getAuthWhitelist(), req.url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = req.headers['x-token'] as string;
|
||||
|
||||
if (typeof token !== 'string') {
|
||||
throw new ApiGatewayErrors.UnAuthorizedError(
|
||||
ApiGatewayErrors.ERR_NO_TOKEN,
|
||||
{
|
||||
error: 'No Token',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
try {
|
||||
const user: UserJWTPayload = await ctx.call('user.resolveToken', {
|
||||
token,
|
||||
});
|
||||
|
||||
if (user && user._id) {
|
||||
this.logger.info('[Web] Authenticated via JWT: ', user.nickname);
|
||||
// Reduce user fields (it will be transferred to other nodes)
|
||||
ctx.meta.user = _.pick(user, ['_id', 'nickname', 'email', 'avatar']);
|
||||
ctx.meta.token = token;
|
||||
ctx.meta.userId = user._id;
|
||||
} else {
|
||||
throw new Error(t('Token不合规'));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new ApiGatewayErrors.UnAuthorizedError(
|
||||
ApiGatewayErrors.ERR_INVALID_TOKEN,
|
||||
{
|
||||
error: 'Invalid Token:' + String(err),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1314
server/services/core/group/group.service.ts
Normal file
1314
server/services/core/group/group.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
190
server/services/core/group/groupExtra.service.ts
Normal file
190
server/services/core/group/groupExtra.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import _ from 'lodash';
|
||||
import type {
|
||||
GroupExtraDocument,
|
||||
GroupExtraModel,
|
||||
} from '../../../models/group/group-extra';
|
||||
import {
|
||||
TcService,
|
||||
TcContext,
|
||||
TcDbService,
|
||||
call,
|
||||
PERMISSION,
|
||||
t,
|
||||
NoPermissionError,
|
||||
} from 'tailchat-server-sdk';
|
||||
|
||||
interface GroupExtraService
|
||||
extends TcService,
|
||||
TcDbService<GroupExtraDocument, GroupExtraModel> {}
|
||||
class GroupExtraService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'group.extra';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/group/group-extra').default);
|
||||
|
||||
this.registerAction('getGroupData', this.getGroupData, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
name: 'string',
|
||||
},
|
||||
cache: {
|
||||
keys: ['groupId', 'name'],
|
||||
ttl: 60 * 60, // 1 hour
|
||||
},
|
||||
});
|
||||
this.registerAction('saveGroupData', this.saveGroupData, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
name: 'string',
|
||||
data: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('getPanelData', this.getPanelData, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
panelId: 'string',
|
||||
name: 'string',
|
||||
},
|
||||
cache: {
|
||||
keys: ['groupId', 'panelId', 'name'],
|
||||
ttl: 60 * 60, // 1 hour
|
||||
},
|
||||
});
|
||||
this.registerAction('savePanelData', this.savePanelData, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
panelId: 'string',
|
||||
name: 'string',
|
||||
data: 'string',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getGroupData(
|
||||
ctx: TcContext<{
|
||||
groupId: string;
|
||||
name: string;
|
||||
}>
|
||||
) {
|
||||
const { groupId, name } = ctx.params;
|
||||
|
||||
const res = await this.adapter.findOne({
|
||||
groupId,
|
||||
panelId: null,
|
||||
name,
|
||||
});
|
||||
|
||||
return { data: res?.data ?? null };
|
||||
}
|
||||
|
||||
async saveGroupData(
|
||||
ctx: TcContext<{
|
||||
groupId: string;
|
||||
name: string;
|
||||
data: string;
|
||||
}>
|
||||
) {
|
||||
const { groupId, name, data } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const [hasPermission] = await call(ctx).checkUserPermissions(
|
||||
groupId,
|
||||
userId,
|
||||
[PERMISSION.core.groupConfig]
|
||||
);
|
||||
if (!hasPermission) {
|
||||
throw new NoPermissionError(t('没有操作权限'));
|
||||
}
|
||||
|
||||
await this.adapter.model.findOneAndUpdate(
|
||||
{
|
||||
groupId,
|
||||
panelId: null,
|
||||
name,
|
||||
},
|
||||
{
|
||||
data: String(data),
|
||||
},
|
||||
{
|
||||
upsert: true, // Create if not exist
|
||||
}
|
||||
);
|
||||
|
||||
await this.cleanGroupDataCache(groupId, name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getPanelData(
|
||||
ctx: TcContext<{
|
||||
groupId: string;
|
||||
panelId: string;
|
||||
name: string;
|
||||
}>
|
||||
) {
|
||||
const { groupId, panelId, name } = ctx.params;
|
||||
|
||||
const res = await this.adapter.findOne({
|
||||
groupId,
|
||||
panelId,
|
||||
name,
|
||||
});
|
||||
|
||||
return { data: res?.data ?? null };
|
||||
}
|
||||
|
||||
async savePanelData(
|
||||
ctx: TcContext<{
|
||||
groupId: string;
|
||||
panelId: string;
|
||||
name: string;
|
||||
data: string;
|
||||
}>
|
||||
) {
|
||||
const { groupId, panelId, name, data } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const [hasPermission] = await call(ctx).checkUserPermissions(
|
||||
groupId,
|
||||
userId,
|
||||
[PERMISSION.core.managePanel]
|
||||
);
|
||||
if (!hasPermission) {
|
||||
throw new NoPermissionError(t('没有操作权限'));
|
||||
}
|
||||
|
||||
await this.adapter.model.findOneAndUpdate(
|
||||
{
|
||||
groupId,
|
||||
panelId,
|
||||
name,
|
||||
},
|
||||
{
|
||||
data: String(data),
|
||||
},
|
||||
{
|
||||
upsert: true, // Create if not exist
|
||||
}
|
||||
);
|
||||
|
||||
await this.cleanGroupPanelDataCache(groupId, panelId, name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private cleanGroupDataCache(groupId: string, name: string) {
|
||||
return this.cleanActionCache('getGroupData', [groupId, name]);
|
||||
}
|
||||
|
||||
private cleanGroupPanelDataCache(
|
||||
groupId: string,
|
||||
panelId: string,
|
||||
name: string
|
||||
) {
|
||||
return this.cleanActionCache('getPanelData', [groupId, panelId, name]);
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupExtraService;
|
||||
263
server/services/core/group/invite.service.ts
Normal file
263
server/services/core/group/invite.service.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import _ from 'lodash';
|
||||
import type {
|
||||
GroupInvite,
|
||||
GroupInviteDocument,
|
||||
GroupInviteModel,
|
||||
} from '../../../models/group/invite';
|
||||
import {
|
||||
TcService,
|
||||
TcContext,
|
||||
TcDbService,
|
||||
PureContext,
|
||||
call,
|
||||
NoPermissionError,
|
||||
PERMISSION,
|
||||
db,
|
||||
} from 'tailchat-server-sdk';
|
||||
|
||||
interface GroupService
|
||||
extends TcService,
|
||||
TcDbService<GroupInviteDocument, GroupInviteModel> {}
|
||||
class GroupService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'group.invite';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/group/invite').default);
|
||||
|
||||
this.registerAction('createGroupInvite', this.createGroupInvite, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
inviteType: { type: 'enum', values: ['normal', 'permanent'] },
|
||||
},
|
||||
});
|
||||
this.registerAction('editGroupInvite', this.editGroupInvite, {
|
||||
params: {
|
||||
code: 'string',
|
||||
groupId: 'string',
|
||||
expiredAt: { type: 'number', optional: true },
|
||||
usageLimit: { type: 'number', optional: true },
|
||||
},
|
||||
});
|
||||
this.registerAction('getAllGroupInviteCode', this.getAllGroupInviteCode, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('findInviteByCode', this.findInviteByCode, {
|
||||
params: {
|
||||
code: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('applyInvite', this.applyInvite, {
|
||||
params: {
|
||||
code: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('deleteInvite', this.deleteInvite, {
|
||||
params: {
|
||||
groupId: 'string',
|
||||
inviteId: 'string',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群组邀请
|
||||
*/
|
||||
async createGroupInvite(
|
||||
ctx: TcContext<{
|
||||
groupId: string;
|
||||
inviteType: 'normal' | 'permanent';
|
||||
}>
|
||||
): Promise<GroupInvite> {
|
||||
const { groupId, inviteType } = ctx.params;
|
||||
const { userId, t } = ctx.meta;
|
||||
|
||||
const [hasNormalPermission, hasUnlimitedPermission] = await call(
|
||||
ctx
|
||||
).checkUserPermissions(groupId, userId, [
|
||||
PERMISSION.core.invite,
|
||||
PERMISSION.core.unlimitedInvite,
|
||||
]);
|
||||
|
||||
if (
|
||||
(inviteType === 'normal' && !hasNormalPermission) ||
|
||||
(inviteType === 'permanent' && !hasUnlimitedPermission)
|
||||
) {
|
||||
throw new NoPermissionError(t('没有创建邀请码权限'));
|
||||
}
|
||||
|
||||
const invite = await this.adapter.model.createGroupInvite(
|
||||
groupId,
|
||||
userId,
|
||||
inviteType
|
||||
);
|
||||
return await this.transformDocuments(ctx, {}, invite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑群组邀请码
|
||||
*/
|
||||
async editGroupInvite(
|
||||
ctx: TcContext<{
|
||||
code: string;
|
||||
groupId: string;
|
||||
expiredAt?: number; // 时间戳,单位ms
|
||||
usageLimit?: number;
|
||||
}>
|
||||
) {
|
||||
const { code, groupId, expiredAt, usageLimit } = ctx.params;
|
||||
const { userId, t } = ctx.meta;
|
||||
|
||||
// 检查权限
|
||||
const [hasEditPermission] = await call(ctx).checkUserPermissions(
|
||||
groupId,
|
||||
userId,
|
||||
[PERMISSION.core.editInvite]
|
||||
);
|
||||
|
||||
if (!hasEditPermission) {
|
||||
throw new NoPermissionError(t('没有编辑邀请码权限'));
|
||||
}
|
||||
|
||||
const update = {};
|
||||
if (expiredAt) {
|
||||
_.set(update, ['expiredAt'], new Date(expiredAt));
|
||||
} else {
|
||||
_.set(update, ['$unset', 'expiredAt'], 1);
|
||||
}
|
||||
|
||||
if (usageLimit) {
|
||||
_.set(update, ['usageLimit'], usageLimit);
|
||||
} else {
|
||||
_.set(update, ['$unset', 'usageLimit'], 1);
|
||||
}
|
||||
|
||||
await this.adapter.model.updateOne({ groupId, code }, update);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有群组邀请码
|
||||
*/
|
||||
async getAllGroupInviteCode(
|
||||
ctx: TcContext<{
|
||||
groupId: string;
|
||||
}>
|
||||
) {
|
||||
const groupId = ctx.params.groupId;
|
||||
const { t, userId } = ctx.meta;
|
||||
|
||||
const [hasPermission] = await call(ctx).checkUserPermissions(
|
||||
groupId,
|
||||
userId,
|
||||
[PERMISSION.core.manageInvite]
|
||||
);
|
||||
if (!hasPermission) {
|
||||
throw new NoPermissionError(t('没有查看权限'));
|
||||
}
|
||||
|
||||
const list = await this.adapter.model.find({
|
||||
groupId,
|
||||
});
|
||||
|
||||
return await this.transformDocuments(ctx, {}, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邀请码查找群组邀请信息
|
||||
*/
|
||||
async findInviteByCode(
|
||||
ctx: PureContext<{
|
||||
code: string;
|
||||
}>
|
||||
): Promise<GroupInvite | null> {
|
||||
const code = ctx.params.code;
|
||||
|
||||
const invite = await this.adapter.model.findOne({
|
||||
code,
|
||||
});
|
||||
|
||||
return await this.transformDocuments(ctx, {}, invite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用群组邀请(通过群组邀请加入群组)
|
||||
*/
|
||||
async applyInvite(ctx: TcContext<{ code: string }>): Promise<void> {
|
||||
const code = ctx.params.code;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
const invite = await this.adapter.model.findOne({
|
||||
code,
|
||||
});
|
||||
|
||||
if (typeof invite.usageLimit === 'number') {
|
||||
const usage = invite.usage || 0;
|
||||
if (usage >= invite.usageLimit) {
|
||||
throw new Error(t('该邀请码使用次数耗尽'));
|
||||
}
|
||||
}
|
||||
|
||||
if (new Date(invite.expiredAt).valueOf() < Date.now()) {
|
||||
throw new Error(t('该邀请码已过期'));
|
||||
}
|
||||
|
||||
const groupId = invite.groupId;
|
||||
if (_.isNil(groupId)) {
|
||||
throw new Error(t('群组邀请失效: 群组id为空'));
|
||||
}
|
||||
|
||||
await ctx.call('group.joinGroup', {
|
||||
groupId: String(groupId),
|
||||
});
|
||||
|
||||
await this.adapter.model.updateOne(
|
||||
{
|
||||
_id: new db.Types.ObjectId(invite._id),
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
usage: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const creatorInfo = await call(ctx).getUserInfo(String(invite.creator));
|
||||
await call(ctx).addGroupSystemMessage(
|
||||
String(groupId),
|
||||
t('{{nickname}} 通过 {{creator}} 的邀请码加入群组', {
|
||||
nickname: ctx.meta.user.nickname,
|
||||
creator: creatorInfo.nickname,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除邀请码
|
||||
*/
|
||||
async deleteInvite(ctx: TcContext<{ groupId: string; inviteId: string }>) {
|
||||
const groupId = ctx.params.groupId;
|
||||
const inviteId = ctx.params.inviteId;
|
||||
const { t, userId } = ctx.meta;
|
||||
|
||||
const [hasPermission] = await call(ctx).checkUserPermissions(
|
||||
groupId,
|
||||
userId,
|
||||
[PERMISSION.core.manageInvite]
|
||||
);
|
||||
if (!hasPermission) {
|
||||
throw new NoPermissionError(t('没有删除权限'));
|
||||
}
|
||||
|
||||
await this.adapter.model.deleteOne({
|
||||
_id: inviteId,
|
||||
groupId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupService;
|
||||
45
server/services/core/plugin/registry.service.ts
Normal file
45
server/services/core/plugin/registry.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginManifestDocument,
|
||||
PluginManifestModel,
|
||||
} from '../../../models/plugin/manifest';
|
||||
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
|
||||
|
||||
interface PluginRegistryService
|
||||
extends TcService,
|
||||
TcDbService<PluginManifestDocument, PluginManifestModel> {}
|
||||
class PluginRegistryService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'plugin.registry';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/plugin/manifest').default);
|
||||
this.registerDbField([
|
||||
'label',
|
||||
'name',
|
||||
'url',
|
||||
'icon',
|
||||
'version',
|
||||
'author',
|
||||
'description',
|
||||
'requireRestart',
|
||||
]);
|
||||
|
||||
this.registerAction('list', this.getPluginList, {
|
||||
cache: {
|
||||
enabled: true,
|
||||
ttl: 60 * 60, // 1 hour
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPluginList(ctx: TcContext): Promise<{
|
||||
list: PluginManifest[];
|
||||
}> {
|
||||
const docs = await this.adapter.find({});
|
||||
return await this.transformDocuments(ctx, {}, docs);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginRegistryService;
|
||||
89
server/services/core/user/dmlist.service.ts
Normal file
89
server/services/core/user/dmlist.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Ref } from '@typegoose/typegoose';
|
||||
import type { Converse } from '../../../models/chat/converse';
|
||||
import type {
|
||||
UserDMList,
|
||||
UserDMListDocument,
|
||||
UserDMListModel,
|
||||
} from '../../../models/user/dmlist';
|
||||
import { TcService, TcContext, TcDbService, db } from 'tailchat-server-sdk';
|
||||
|
||||
interface UserDMListService
|
||||
extends TcService,
|
||||
TcDbService<UserDMListDocument, UserDMListModel> {}
|
||||
class UserDMListService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'user.dmlist';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/user/dmlist').default);
|
||||
this.registerAction('addConverse', this.addConverse, {
|
||||
params: {
|
||||
converseId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('removeConverse', this.removeConverse, {
|
||||
params: {
|
||||
converseId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('getAllConverse', this.getAllConverse);
|
||||
}
|
||||
|
||||
async addConverse(ctx: TcContext<{ converseId: string }>) {
|
||||
const userId = ctx.meta.userId;
|
||||
const converseId = ctx.params.converseId;
|
||||
|
||||
const record = await this.adapter.model.findOrCreate({
|
||||
userId,
|
||||
});
|
||||
|
||||
const res = await this.adapter.model.findByIdAndUpdate(record.doc._id, {
|
||||
$addToSet: {
|
||||
converseIds: new db.Types.ObjectId(converseId),
|
||||
},
|
||||
});
|
||||
|
||||
return await this.transformDocuments(ctx, {}, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除会话
|
||||
*/
|
||||
async removeConverse(ctx: TcContext<{ converseId: string }>) {
|
||||
const userId = ctx.meta.userId;
|
||||
const converseId = ctx.params.converseId;
|
||||
|
||||
const { modifiedCount } = await this.adapter.model
|
||||
.updateOne(
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
$pull: {
|
||||
converseIds: converseId,
|
||||
},
|
||||
}
|
||||
)
|
||||
.exec();
|
||||
|
||||
return { modifiedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有会话
|
||||
*/
|
||||
async getAllConverse(ctx: TcContext): Promise<Ref<Converse>[]> {
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const doc = await this.adapter.model.findOne({
|
||||
userId,
|
||||
});
|
||||
|
||||
const res: UserDMList | null = await this.transformDocuments(ctx, {}, doc);
|
||||
|
||||
return res?.converseIds ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export default UserDMListService;
|
||||
136
server/services/core/user/friend.service.ts
Normal file
136
server/services/core/user/friend.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
Friend,
|
||||
FriendDocument,
|
||||
FriendModel,
|
||||
} from '../../../models/user/friend';
|
||||
import { TcService, TcDbService, TcContext } from 'tailchat-server-sdk';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
interface FriendService
|
||||
extends TcService,
|
||||
TcDbService<FriendDocument, FriendModel> {}
|
||||
class FriendService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'friend';
|
||||
}
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/user/friend').default);
|
||||
// this.registerMixin(TcCacheCleaner(['cache.clean.friend']));
|
||||
|
||||
this.registerAction('getAllFriends', this.getAllFriends);
|
||||
this.registerAction('buildFriendRelation', this.buildFriendRelation, {
|
||||
params: {
|
||||
user1: 'string',
|
||||
user2: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('removeFriend', this.removeFriend, {
|
||||
params: {
|
||||
friendUserId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('checkIsFriend', this.checkIsFriend, {
|
||||
params: {
|
||||
targetId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('setFriendNickname', this.setFriendNickname, {
|
||||
params: {
|
||||
targetId: 'string',
|
||||
nickname: 'string',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有好友
|
||||
*/
|
||||
async getAllFriends(ctx: TcContext<{}>) {
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const list = await this.adapter.find({
|
||||
query: {
|
||||
from: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const records: Friend[] = await this.transformDocuments(ctx, {}, list);
|
||||
const res = records.map((r) => ({
|
||||
id: r.to,
|
||||
nickname: r.nickname,
|
||||
}));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建好友关系
|
||||
*/
|
||||
async buildFriendRelation(ctx: TcContext<{ user1: string; user2: string }>) {
|
||||
const { user1, user2 } = ctx.params;
|
||||
await this.adapter.model.buildFriendRelation(user1, user2);
|
||||
|
||||
this.unicastNotify(ctx, user1, 'add', {
|
||||
userId: user2,
|
||||
});
|
||||
this.unicastNotify(ctx, user2, 'add', {
|
||||
userId: user1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单项好友关系
|
||||
*/
|
||||
async removeFriend(ctx: TcContext<{ friendUserId: string }>) {
|
||||
const { friendUserId } = ctx.params;
|
||||
const { userId } = ctx.meta;
|
||||
|
||||
await this.adapter.model.findOneAndRemove({
|
||||
from: userId,
|
||||
to: friendUserId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对方是否为自己好友
|
||||
*/
|
||||
async checkIsFriend(ctx: TcContext<{ targetId: string }>) {
|
||||
const { targetId } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const isFriend = await this.adapter.model.exists({
|
||||
from: userId,
|
||||
to: targetId,
|
||||
});
|
||||
|
||||
return isFriend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置好友昵称
|
||||
*/
|
||||
async setFriendNickname(
|
||||
ctx: TcContext<{ targetId: string; nickname: string }>
|
||||
) {
|
||||
const { targetId, nickname } = ctx.params;
|
||||
const userId = ctx.meta.userId;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
const res = await this.adapter.model.findOneAndUpdate(
|
||||
{
|
||||
from: userId,
|
||||
to: targetId,
|
||||
},
|
||||
{
|
||||
nickname: nickname,
|
||||
}
|
||||
);
|
||||
|
||||
if (isNil(res)) {
|
||||
throw new Error(t('设置昵称失败, 没有找到好友关系信息'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export default FriendService;
|
||||
190
server/services/core/user/friendRequest.service.ts
Normal file
190
server/services/core/user/friendRequest.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
TcService,
|
||||
TcDbService,
|
||||
TcContext,
|
||||
Errors,
|
||||
DataNotFoundError,
|
||||
NoPermissionError,
|
||||
config,
|
||||
} from 'tailchat-server-sdk';
|
||||
import _ from 'lodash';
|
||||
import type { FriendRequest } from '../../../models/user/friendRequest';
|
||||
|
||||
interface FriendService extends TcService, TcDbService<any> {}
|
||||
class FriendService extends TcService {
|
||||
get serviceName(): string {
|
||||
return 'friend.request';
|
||||
}
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/user/friendRequest').default);
|
||||
// this.registerMixin(TcCacheCleaner(['cache.clean.friend']));
|
||||
|
||||
this.registerAction('add', this.add, {
|
||||
params: {
|
||||
to: 'string',
|
||||
message: [{ type: 'string', optional: true }],
|
||||
},
|
||||
});
|
||||
this.registerAction('allRelated', this.allRelated);
|
||||
this.registerAction('accept', this.accept, {
|
||||
params: {
|
||||
requestId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('deny', this.deny, {
|
||||
params: {
|
||||
requestId: 'string',
|
||||
},
|
||||
});
|
||||
this.registerAction('cancel', this.cancel, {
|
||||
params: {
|
||||
requestId: 'string',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求添加好友
|
||||
*/
|
||||
async add(ctx: TcContext<{ to: string; message?: string }>) {
|
||||
const from = ctx.meta.userId;
|
||||
const t = ctx.meta.t;
|
||||
|
||||
const { to, message } = ctx.params;
|
||||
|
||||
if (config.feature.disableAddFriend === true) {
|
||||
throw new NoPermissionError(t('管理员禁止添加好友功能'));
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
throw new Errors.MoleculerError(t('不能添加自己为好友'));
|
||||
}
|
||||
|
||||
const exist = await this.adapter.findOne({
|
||||
from,
|
||||
to,
|
||||
});
|
||||
if (exist) {
|
||||
throw new Errors.MoleculerError(t('不能发送重复的好友请求'));
|
||||
}
|
||||
|
||||
const isFriend = await ctx.call('friend.checkIsFriend', { targetId: to });
|
||||
if (isFriend) {
|
||||
throw new Error(t('对方已经是您的好友, 不能再次添加'));
|
||||
}
|
||||
|
||||
const doc = await this.adapter.insert({
|
||||
from,
|
||||
to,
|
||||
message,
|
||||
});
|
||||
const request = await this.transformDocuments(ctx, {}, doc);
|
||||
|
||||
this.listcastNotify(ctx, [from, to], 'add', request);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有与自己相关的好友请求
|
||||
*/
|
||||
async allRelated(ctx: TcContext) {
|
||||
const userId = ctx.meta.userId;
|
||||
|
||||
const doc = await this.adapter.find({
|
||||
query: {
|
||||
$or: [{ from: userId }, { to: userId }],
|
||||
},
|
||||
});
|
||||
|
||||
const list = await await this.transformDocuments(ctx, {}, doc);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受好友请求
|
||||
*/
|
||||
async accept(ctx: TcContext<{ requestId: string }>) {
|
||||
const requestId = ctx.params.requestId;
|
||||
|
||||
const request: FriendRequest = await this.adapter.findById(requestId);
|
||||
if (_.isNil(request)) {
|
||||
throw new DataNotFoundError('该好友请求未找到');
|
||||
}
|
||||
|
||||
if (ctx.meta.userId !== String(request.to)) {
|
||||
throw new NoPermissionError();
|
||||
}
|
||||
|
||||
await ctx.call('friend.buildFriendRelation', {
|
||||
user1: String(request.from),
|
||||
user2: String(request.to),
|
||||
});
|
||||
|
||||
await this.adapter.removeById(request._id);
|
||||
|
||||
this.listcastNotify(
|
||||
ctx,
|
||||
[String(request.from), String(request.to)],
|
||||
'remove',
|
||||
{
|
||||
requestId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝好友请求
|
||||
*/
|
||||
async deny(ctx: TcContext<{ requestId: string }>) {
|
||||
const requestId = ctx.params.requestId;
|
||||
|
||||
const request: FriendRequest = await this.adapter.findById(requestId);
|
||||
if (_.isNil(request)) {
|
||||
throw new DataNotFoundError('该好友请求未找到');
|
||||
}
|
||||
|
||||
if (ctx.meta.userId !== String(request.to)) {
|
||||
throw new NoPermissionError();
|
||||
}
|
||||
|
||||
await this.adapter.removeById(request._id);
|
||||
|
||||
this.listcastNotify(
|
||||
ctx,
|
||||
[String(request.from), String(request.to)],
|
||||
'remove',
|
||||
{
|
||||
requestId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消好友请求
|
||||
*/
|
||||
async cancel(ctx: TcContext<{ requestId: string }>) {
|
||||
const requestId = ctx.params.requestId;
|
||||
|
||||
const request: FriendRequest = await this.adapter.findById(requestId);
|
||||
if (_.isNil(request)) {
|
||||
throw new DataNotFoundError('该好友请求未找到');
|
||||
}
|
||||
|
||||
if (ctx.meta.userId !== String(request.from)) {
|
||||
throw new NoPermissionError();
|
||||
}
|
||||
|
||||
await this.adapter.removeById(request._id);
|
||||
|
||||
this.listcastNotify(
|
||||
ctx,
|
||||
[String(request.from), String(request.to)],
|
||||
'remove',
|
||||
{
|
||||
requestId,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
export default FriendService;
|
||||
76
server/services/core/user/mail.service.ts
Normal file
76
server/services/core/user/mail.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { MailDocument, MailModel } from '../../../models/user/mail';
|
||||
import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk';
|
||||
import ejs from 'ejs';
|
||||
import path from 'path';
|
||||
|
||||
interface MailService extends TcService, TcDbService<MailDocument, MailModel> {}
|
||||
class MailService extends TcService {
|
||||
smtpServiceAvailable = false;
|
||||
|
||||
get serviceName(): string {
|
||||
return 'mail';
|
||||
}
|
||||
|
||||
onInit(): void {
|
||||
this.registerLocalDb(require('../../../models/user/mail').default);
|
||||
|
||||
this.registerAction('sendMail', this.sendMail, {
|
||||
visibility: 'public',
|
||||
params: {
|
||||
to: 'string',
|
||||
subject: 'string',
|
||||
html: 'string',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onInited() {
|
||||
this.adapter.model.verifyMailService().then((available) => {
|
||||
if (available) {
|
||||
this.logger.info('SMTP 服务可用');
|
||||
} else {
|
||||
this.logger.warn('SMTP 服务不可用');
|
||||
}
|
||||
|
||||
this.smtpServiceAvailable = available;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
*/
|
||||
async sendMail(
|
||||
ctx: TcContext<{
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}>
|
||||
) {
|
||||
if (!this.smtpServiceAvailable) {
|
||||
throw new Error('SMTP 服务不可用');
|
||||
}
|
||||
|
||||
const { to, subject, html } = ctx.params;
|
||||
const { t } = ctx.meta;
|
||||
|
||||
try {
|
||||
const info = await this.adapter.model.sendMail({
|
||||
to,
|
||||
subject,
|
||||
html: await ejs.renderFile(
|
||||
path.resolve(__dirname, '../../../views/mail.ejs'),
|
||||
{
|
||||
body: html,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
this.logger.info('sendMailSuccess:', info);
|
||||
} catch (err) {
|
||||
this.logger.error('sendMailFailed:', err);
|
||||
throw new Error(t('邮件发送失败'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MailService;
|
||||
1279
server/services/core/user/user.service.ts
Normal file
1279
server/services/core/user/user.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user