664 lines
18 KiB
TypeScript
664 lines
18 KiB
TypeScript
/**
|
||
* Sales Chat 统一插件服务
|
||
*
|
||
* 所有操作合并到一个服务 plugin:com.msgbyte.saleschat 中,
|
||
* 以避免 Tailchat 网关 mappingPolicy:'all' 对多冒号服务名路由失败的问题。
|
||
*
|
||
* 主数据库模型:Invite(通过 registerLocalDb)
|
||
* 辅助模型:AccessLog, Stats, KickRecord, DeletionRecord(通过直接 require)
|
||
*/
|
||
|
||
import { TcService, TcDbService, TcContext, call } from 'tailchat-server-sdk';
|
||
import type { InviteDocument, InviteModel } from '../models/invite';
|
||
import { nanoid } from 'nanoid';
|
||
import QRCode from 'qrcode';
|
||
|
||
interface SalesChatService
|
||
extends TcService,
|
||
TcDbService<InviteDocument, InviteModel> {}
|
||
class SalesChatService extends TcService {
|
||
get serviceName(): string {
|
||
return 'plugin:com.msgbyte.saleschat';
|
||
}
|
||
|
||
private _accessLogModel: any;
|
||
private _statsModel: any;
|
||
private _deletionRecordModel: any;
|
||
private _kickRecordModel: any;
|
||
|
||
onInit(): void {
|
||
this.registerLocalDb(require('../models/invite').default);
|
||
this._accessLogModel = require('../models/accesslog').default;
|
||
this._statsModel = require('../models/stats').default;
|
||
this._deletionRecordModel = require('../models/deletionrecord').default;
|
||
this._kickRecordModel = require('../models/kickrecord').default;
|
||
|
||
// ====== 邀请相关 ======
|
||
this.registerAction('inviteCreate', this.inviteCreate, {
|
||
params: {
|
||
groupId: 'string',
|
||
expiresIn: { type: 'number', optional: true },
|
||
},
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('inviteGetMyInvites', this.inviteGetMyInvites, {
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('inviteGetByCode', this.inviteGetByCode, {
|
||
params: { code: 'string' },
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('inviteGetStats', this.inviteGetStats, {
|
||
params: { code: 'string' },
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('inviteDeactivate', this.inviteDeactivate, {
|
||
params: { code: 'string' },
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('inviteJoin', this.inviteJoin, {
|
||
params: { code: 'string' },
|
||
visibility: 'published',
|
||
});
|
||
|
||
// ====== 统计相关 ======
|
||
this.registerAction('statsGetMyStats', this.statsGetMyStats, {
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('statsGetTeamStats', this.statsGetTeamStats, {
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('statsGetPlatformStats', this.statsGetPlatformStats, {
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('statsGetRanking', this.statsGetRanking, {
|
||
params: { period: { type: 'string', optional: true } },
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('statsGetTrend', this.statsGetTrend, {
|
||
params: {
|
||
salesId: 'string',
|
||
period: { type: 'string', optional: true },
|
||
},
|
||
visibility: 'published',
|
||
});
|
||
|
||
// ====== 管理员相关 ======
|
||
this.registerAction('adminGetUserList', this.adminGetUserList, {
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('adminUpdateUserRole', this.adminUpdateUserRole, {
|
||
params: { userId: 'string', role: 'string' },
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('adminDeleteUser', this.adminDeleteUser, {
|
||
params: {
|
||
userId: 'string',
|
||
type: { type: 'string', optional: true },
|
||
reason: { type: 'string', optional: true },
|
||
},
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('adminGetGroupList', this.adminGetGroupList, {
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('adminGetGroupStats', this.adminGetGroupStats, {
|
||
params: { groupId: 'string' },
|
||
visibility: 'published',
|
||
});
|
||
|
||
this.registerAction('adminKickUser', this.adminKickUser, {
|
||
params: {
|
||
groupId: 'string',
|
||
userId: 'string',
|
||
reason: { type: 'string', optional: true },
|
||
},
|
||
visibility: 'published',
|
||
});
|
||
|
||
console.log('Sales Chat Plugin 已加载');
|
||
}
|
||
|
||
// ============================================================
|
||
// 邀请操作
|
||
// ============================================================
|
||
|
||
async inviteCreate(ctx: TcContext<{ groupId: string; expiresIn?: number }>) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const { groupId, expiresIn } = ctx.params;
|
||
|
||
const group = await call(ctx).getGroupInfo(groupId);
|
||
if (!group) throw new Error('群组不存在');
|
||
|
||
const code = nanoid(8);
|
||
const baseUrl = process.env.INVITE_BASE_URL || 'http://localhost:11000';
|
||
const link = `${baseUrl}/join/${code}`;
|
||
const expiresAt = expiresIn
|
||
? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000)
|
||
: null;
|
||
|
||
const qrCodeUrl = await QRCode.toDataURL(link, {
|
||
width: 300,
|
||
margin: 2,
|
||
});
|
||
|
||
const doc = await this.adapter.model.create({
|
||
code,
|
||
salesId: userId,
|
||
groupId,
|
||
link,
|
||
qrCodeUrl,
|
||
expiresAt,
|
||
clickCount: 0,
|
||
scanCount: 0,
|
||
joinCount: 0,
|
||
status: 'active',
|
||
});
|
||
|
||
return await this.transformDocuments(ctx, {}, doc);
|
||
}
|
||
|
||
async inviteGetMyInvites(ctx: TcContext) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const docs = await this.adapter.model
|
||
.find({ salesId: userId })
|
||
.sort('-createdAt')
|
||
.exec();
|
||
|
||
return await this.transformDocuments(ctx, {}, docs);
|
||
}
|
||
|
||
async inviteGetByCode(ctx: TcContext<{ code: string }>) {
|
||
const { code } = ctx.params;
|
||
|
||
const invite = await this.adapter.model.findOne({ code }).exec();
|
||
if (!invite) throw new Error('邀请不存在');
|
||
if (invite.expiresAt && new Date() > invite.expiresAt)
|
||
throw new Error('邀请已过期');
|
||
|
||
await this.adapter.model.updateOne(
|
||
{ _id: invite._id },
|
||
{ $inc: { clickCount: 1 } }
|
||
);
|
||
|
||
return {
|
||
code: invite.code,
|
||
link: invite.link,
|
||
groupId: invite.groupId,
|
||
createdAt: invite.createdAt,
|
||
expiresAt: invite.expiresAt,
|
||
};
|
||
}
|
||
|
||
async inviteGetStats(ctx: TcContext<{ code: string }>) {
|
||
const { code } = ctx.params;
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const invite = await this.adapter.model.findOne({ code }).exec();
|
||
if (!invite) throw new Error('邀请不存在');
|
||
if (String(invite.salesId) !== String(userId))
|
||
throw new Error('无权限查看');
|
||
|
||
const AccessLog = this._accessLogModel;
|
||
const logs = await AccessLog.find({ inviteCode: code })
|
||
.sort('-timestamp')
|
||
.limit(10)
|
||
.exec();
|
||
|
||
return {
|
||
code: invite.code,
|
||
link: invite.link,
|
||
createdAt: invite.createdAt,
|
||
expiresAt: invite.expiresAt,
|
||
stats: {
|
||
clicks: invite.clickCount,
|
||
scans: invite.scanCount,
|
||
joins: invite.joinCount,
|
||
conversionRate:
|
||
invite.clickCount > 0
|
||
? ((invite.joinCount / invite.clickCount) * 100).toFixed(2)
|
||
: '0',
|
||
},
|
||
recentAccess: logs.map((l: any) => l.toObject()),
|
||
};
|
||
}
|
||
|
||
async inviteDeactivate(ctx: TcContext<{ code: string }>) {
|
||
const { code } = ctx.params;
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const invite = await this.adapter.model.findOne({ code }).exec();
|
||
if (!invite) throw new Error('邀请不存在');
|
||
if (String(invite.salesId) !== String(userId))
|
||
throw new Error('无权限操作');
|
||
|
||
await this.adapter.model.updateOne(
|
||
{ _id: invite._id },
|
||
{ $set: { status: 'inactive', expiresAt: new Date(0) } }
|
||
);
|
||
|
||
return { message: '邀请已停用' };
|
||
}
|
||
|
||
async inviteJoin(ctx: TcContext<{ code: string }>) {
|
||
const { code } = ctx.params;
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const invite = await this.adapter.model.findOne({ code }).exec();
|
||
if (!invite) throw new Error('邀请不存在');
|
||
if (invite.expiresAt && new Date() > invite.expiresAt)
|
||
throw new Error('邀请已过期');
|
||
|
||
await ctx.call('group.joinGroup', {
|
||
groupId: invite.groupId,
|
||
userId,
|
||
});
|
||
|
||
await this.adapter.model.updateOne(
|
||
{ _id: invite._id },
|
||
{ $inc: { joinCount: 1 } }
|
||
);
|
||
|
||
const AccessLog = this._accessLogModel;
|
||
await AccessLog.create({
|
||
inviteCode: code,
|
||
accessType: 'join',
|
||
timestamp: new Date(),
|
||
});
|
||
|
||
return { message: '加入成功', groupId: invite.groupId };
|
||
}
|
||
|
||
// ============================================================
|
||
// 统计操作
|
||
// ============================================================
|
||
|
||
async statsGetMyStats(ctx: TcContext) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const Stats = this._statsModel;
|
||
|
||
const totalStats: any[] = await Stats.aggregate([
|
||
{ $match: { salesId: userId } },
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
totalInvites: { $sum: '$invitesCreated' },
|
||
totalJoins: { $sum: '$joins' },
|
||
totalConversions: { $sum: '$conversions' },
|
||
totalRevenue: { $sum: '$revenue' },
|
||
} as any,
|
||
},
|
||
]).exec();
|
||
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
const todayDoc = await Stats.findOne({
|
||
salesId: userId,
|
||
date: today,
|
||
period: 'daily',
|
||
}).exec();
|
||
|
||
const invites = await this.adapter.model
|
||
.find({ salesId: userId })
|
||
.sort('-createdAt')
|
||
.limit(10)
|
||
.exec();
|
||
const recentInvites = await this.transformDocuments(ctx, {}, invites);
|
||
|
||
return {
|
||
total: totalStats[0] || {
|
||
totalInvites: 0,
|
||
totalJoins: 0,
|
||
totalConversions: 0,
|
||
totalRevenue: 0,
|
||
},
|
||
today: todayDoc
|
||
? await this.transformDocuments(ctx, {}, todayDoc)
|
||
: {
|
||
invitesCreated: 0,
|
||
joins: 0,
|
||
conversions: 0,
|
||
revenue: 0,
|
||
},
|
||
recentInvites,
|
||
};
|
||
}
|
||
|
||
async statsGetTeamStats(ctx: TcContext) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const Stats = this._statsModel;
|
||
|
||
const teamStats: any[] = await Stats.aggregate([
|
||
{
|
||
$group: {
|
||
_id: '$salesId',
|
||
totalInvites: { $sum: '$invitesCreated' },
|
||
totalJoins: { $sum: '$joins' },
|
||
totalConversions: { $sum: '$conversions' },
|
||
totalRevenue: { $sum: '$revenue' },
|
||
} as any,
|
||
},
|
||
{ $sort: { totalJoins: -1 } },
|
||
]).exec();
|
||
|
||
const userIds = teamStats.map((s: any) => s._id);
|
||
const users: any[] = await ctx.call('user.getUsers', { userIds });
|
||
|
||
return teamStats.map((stat: any) => ({
|
||
...stat,
|
||
user: users.find((u: any) => String(u._id) === String(stat._id)),
|
||
}));
|
||
}
|
||
|
||
async statsGetPlatformStats(ctx: TcContext) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const Stats = this._statsModel;
|
||
|
||
const platformStats: any[] = await Stats.aggregate([
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
totalInvites: { $sum: '$invitesCreated' },
|
||
totalJoins: { $sum: '$joins' },
|
||
totalConversions: { $sum: '$conversions' },
|
||
totalRevenue: { $sum: '$revenue' },
|
||
totalUsers: { $sum: 1 },
|
||
} as any,
|
||
},
|
||
]).exec();
|
||
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
const todayStats: any[] = await Stats.aggregate([
|
||
{ $match: { date: today, period: 'daily' } },
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
invitesCreated: { $sum: '$invitesCreated' },
|
||
joins: { $sum: '$joins' },
|
||
conversions: { $sum: '$conversions' },
|
||
revenue: { $sum: '$revenue' },
|
||
} as any,
|
||
},
|
||
]).exec();
|
||
|
||
return {
|
||
platform: platformStats[0] || {},
|
||
today: todayStats[0] || {},
|
||
};
|
||
}
|
||
|
||
async statsGetRanking(ctx: TcContext<{ period?: string }>) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const { period = 'monthly' } = ctx.params || {};
|
||
const Stats = this._statsModel;
|
||
const startDate = this._getStartDate(period);
|
||
|
||
const ranking: any[] = await Stats.aggregate([
|
||
{ $match: { date: { $gte: startDate }, period } },
|
||
{
|
||
$group: {
|
||
_id: '$salesId',
|
||
totalJoins: { $sum: '$joins' },
|
||
totalConversions: { $sum: '$conversions' },
|
||
totalRevenue: { $sum: '$revenue' },
|
||
} as any,
|
||
},
|
||
{ $sort: { totalJoins: -1 } },
|
||
{ $limit: 100 },
|
||
]).exec();
|
||
|
||
const userIds = ranking.map((r: any) => r._id);
|
||
const users: any[] = await ctx.call('user.getUsers', { userIds });
|
||
|
||
return ranking.map((rank: any, index: number) => ({
|
||
rank: index + 1,
|
||
...rank,
|
||
user: users.find((u: any) => String(u._id) === String(rank._id)),
|
||
}));
|
||
}
|
||
|
||
async statsGetTrend(ctx: TcContext<{ salesId: string; period?: string }>) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const { salesId, period = 'daily' } = ctx.params;
|
||
const Stats = this._statsModel;
|
||
const startDate = this._getStartDate('monthly');
|
||
|
||
const docs = await Stats.find({
|
||
salesId,
|
||
date: { $gte: startDate },
|
||
period: period as 'daily' | 'weekly' | 'monthly',
|
||
})
|
||
.sort('date')
|
||
.exec();
|
||
|
||
return await this.transformDocuments(ctx, {}, docs);
|
||
}
|
||
|
||
// ============================================================
|
||
// 管理员操作
|
||
// ============================================================
|
||
|
||
async adminGetUserList(ctx: TcContext) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const users: any[] = await ctx.call('user.getAllUsers');
|
||
return users;
|
||
}
|
||
|
||
async adminUpdateUserRole(ctx: TcContext<{ userId: string; role: string }>) {
|
||
const operatorId = ctx.meta.userId;
|
||
if (!operatorId) throw new Error('未登录');
|
||
|
||
const { userId, role } = ctx.params;
|
||
const validRoles = ['sales', 'admin', 'super_admin'];
|
||
if (!validRoles.includes(role)) throw new Error('无效的角色');
|
||
|
||
await ctx.call('user.updateUser', {
|
||
userId,
|
||
updates: { role },
|
||
});
|
||
|
||
return { message: '角色更新成功' };
|
||
}
|
||
|
||
async adminDeleteUser(
|
||
ctx: TcContext<{
|
||
userId: string;
|
||
type?: string;
|
||
reason?: string;
|
||
}>
|
||
) {
|
||
const operatorId = ctx.meta.userId;
|
||
if (!operatorId) throw new Error('未登录');
|
||
|
||
const { userId, type = 'soft', reason } = ctx.params;
|
||
const user: any = await call(ctx).getUserInfo(userId);
|
||
if (!user) throw new Error('用户不存在');
|
||
|
||
if (type === 'hard') {
|
||
await ctx.call('user.deleteUser', { userId });
|
||
} else {
|
||
await ctx.call('user.updateUser', {
|
||
userId,
|
||
updates: { status: 'deleted' },
|
||
});
|
||
}
|
||
|
||
const DeletionRecord = this._deletionRecordModel;
|
||
await DeletionRecord.create({
|
||
userId,
|
||
username: user.username,
|
||
deletedBy: operatorId,
|
||
type,
|
||
reason: reason || '无',
|
||
});
|
||
|
||
return { message: '删除成功' };
|
||
}
|
||
|
||
async adminGetGroupList(ctx: TcContext) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const groups: any[] = await ctx.call('group.getAllGroups');
|
||
|
||
const allStats: any[] = await this.adapter.model
|
||
.aggregate([
|
||
{
|
||
$group: {
|
||
_id: '$groupId',
|
||
totalInvites: { $sum: 1 },
|
||
totalJoins: { $sum: '$joinCount' },
|
||
} as any,
|
||
},
|
||
])
|
||
.exec();
|
||
|
||
const statsMap = new Map(allStats.map((s: any) => [String(s._id), s]));
|
||
|
||
return groups.map((group: any) => ({
|
||
...group,
|
||
stats: statsMap.get(String(group._id)) || {
|
||
totalInvites: 0,
|
||
totalJoins: 0,
|
||
},
|
||
}));
|
||
}
|
||
|
||
async adminGetGroupStats(ctx: TcContext<{ groupId: string }>) {
|
||
const userId = ctx.meta.userId;
|
||
if (!userId) throw new Error('未登录');
|
||
|
||
const { groupId } = ctx.params;
|
||
|
||
const stats: any[] = await this.adapter.model
|
||
.aggregate([
|
||
{ $match: { groupId } },
|
||
{
|
||
$group: {
|
||
_id: null,
|
||
totalInvites: { $sum: 1 },
|
||
totalClicks: { $sum: '$clickCount' },
|
||
totalScans: { $sum: '$scanCount' },
|
||
totalJoins: { $sum: '$joinCount' },
|
||
} as any,
|
||
},
|
||
])
|
||
.exec();
|
||
|
||
const AccessLog = this._accessLogModel;
|
||
|
||
const groupInviteCodes = await this.adapter.model
|
||
.find({ groupId })
|
||
.select('code -_id')
|
||
.exec();
|
||
const codes = groupInviteCodes.map((inv: any) => inv.code);
|
||
|
||
const recentJoins =
|
||
codes.length > 0
|
||
? await AccessLog.find({
|
||
inviteCode: { $in: codes },
|
||
accessType: 'join',
|
||
})
|
||
.sort('-timestamp')
|
||
.limit(20)
|
||
.exec()
|
||
: [];
|
||
|
||
return {
|
||
stats: stats[0] || {
|
||
totalInvites: 0,
|
||
totalClicks: 0,
|
||
totalScans: 0,
|
||
totalJoins: 0,
|
||
},
|
||
recentJoins: recentJoins.map((l: any) => l.toObject()),
|
||
};
|
||
}
|
||
|
||
async adminKickUser(
|
||
ctx: TcContext<{
|
||
groupId: string;
|
||
userId: string;
|
||
reason?: string;
|
||
}>
|
||
) {
|
||
const operatorId = ctx.meta.userId;
|
||
if (!operatorId) throw new Error('未登录');
|
||
|
||
const { groupId, userId, reason } = ctx.params;
|
||
|
||
const group = await call(ctx).getGroupInfo(groupId);
|
||
if (!group) throw new Error('群组不存在');
|
||
|
||
await ctx.call('group.kickMember', { groupId, userId });
|
||
|
||
const KickRecord = this._kickRecordModel;
|
||
await KickRecord.create({
|
||
groupId,
|
||
userId,
|
||
kickedBy: operatorId,
|
||
reason: reason || '无',
|
||
});
|
||
|
||
return { message: '踢出成功' };
|
||
}
|
||
|
||
// ============================================================
|
||
// 工具方法
|
||
// ============================================================
|
||
|
||
private _getStartDate(period: string): Date {
|
||
const now = new Date();
|
||
switch (period) {
|
||
case 'daily':
|
||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||
case 'weekly':
|
||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||
case 'monthly': {
|
||
const d = new Date(now);
|
||
d.setMonth(d.getMonth() - 12);
|
||
return d;
|
||
}
|
||
default:
|
||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||
}
|
||
}
|
||
}
|
||
|
||
export default SalesChatService;
|