/** * 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 {} 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;