Files
2026-04-25 16:36:34 +08:00

664 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;