Files

664 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2026-04-25 16:36:34 +08:00
/**
* 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;