This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
/**
* 访问日志模型
* TypeGoose 定义,符合 Tailchat 插件标准模式
*/
import { db } from 'tailchat-server-sdk';
const { getModelForClass, prop, modelOptions, TimeStamps, index } = db;
@modelOptions({ options: { customName: 'p_saleschat_accesslog' } })
@index({ inviteCode: 1 })
@index({ timestamp: 1 })
@index({ inviteCode: 1, timestamp: -1 })
export class AccessLog extends TimeStamps implements db.Base {
_id: db.Types.ObjectId;
id: string;
/** 邀请码 */
@prop({ required: true })
inviteCode: string;
/** 访客 ID */
@prop()
visitorId?: string;
/** 访问类型click / scan / join */
@prop({ required: true })
accessType: 'click' | 'scan' | 'join';
/** 访问时间 */
@prop({ default: () => new Date() })
timestamp: Date;
/** IP 地址 */
@prop()
ipAddress?: string;
/** User-Agent */
@prop()
userAgent?: string;
/** 来源 */
@prop()
referrer?: string;
}
export type AccessLogDocument = db.DocumentType<AccessLog>;
const model = getModelForClass(AccessLog);
export type AccessLogModel = typeof model;
export default model;

View File

@@ -0,0 +1,42 @@
/**
* 删除记录模型
* TypeGoose 定义,符合 Tailchat 插件标准模式
*/
import { db } from 'tailchat-server-sdk';
const { getModelForClass, prop, modelOptions, TimeStamps } = db;
@modelOptions({ options: { customName: 'p_saleschat_deletionrecord' } })
export class DeletionRecord extends TimeStamps implements db.Base {
_id: db.Types.ObjectId;
id: string;
/** 被删除用户 ID关联用户 */
@prop({ required: true, ref: 'User' })
userId: db.Types.ObjectId;
/** 用户名 */
@prop()
username?: string;
/** 操作人 ID关联用户 */
@prop({ required: true, ref: 'User' })
deletedBy: db.Types.ObjectId;
/** 删除类型soft / hard */
@prop({ default: 'soft' })
type: 'soft' | 'hard';
/** 原因 */
@prop({ default: '无' })
reason: string;
/** 删除时间 */
@prop({ default: () => new Date() })
deletedAt: Date;
}
export type DeletionRecordDocument = db.DocumentType<DeletionRecord>;
const model = getModelForClass(DeletionRecord);
export type DeletionRecordModel = typeof model;
export default model;

View File

@@ -0,0 +1,30 @@
/**
* 数据模型统一导出
* 所有模型均采用 TypeGoose + TimeStamps 模式,符合 Tailchat 插件标准
*/
export {
default as InviteModel,
type InviteDocument,
type InviteModel as InviteModelType,
} from './invite';
export {
default as StatsModel,
type StatsDocument,
type StatsModel as StatsModelType,
} from './stats';
export {
default as AccessLogModel,
type AccessLogDocument,
type AccessLogModel as AccessLogModelType,
} from './accesslog';
export {
default as KickRecordModel,
type KickRecordDocument,
type KickRecordModel as KickRecordModelType,
} from './kickrecord';
export {
default as DeletionRecordModel,
type DeletionRecordDocument,
type DeletionRecordModel as DeletionRecordModelType,
} from './deletionrecord';

View File

@@ -0,0 +1,62 @@
/**
* 邀请模型
* TypeGoose 定义,符合 Tailchat 插件标准模式
*/
import { db } from 'tailchat-server-sdk';
const { getModelForClass, prop, modelOptions, TimeStamps, index } = db;
@modelOptions({ options: { customName: 'p_saleschat_invite' } })
@index({ code: 1 }, { unique: true })
@index({ salesId: 1 })
@index({ expiresAt: 1 })
@index({ status: 1 })
export class Invite extends TimeStamps implements db.Base {
_id: db.Types.ObjectId;
id: string;
/** 邀请码 */
@prop({ required: true, unique: true })
code: string;
/** 销售 ID关联用户 */
@prop({ required: true, ref: 'User' })
salesId: db.Types.ObjectId;
/** 群组 ID关联群组 */
@prop({ required: true, ref: 'Group' })
groupId: db.Types.ObjectId;
/** 邀请链接 */
@prop()
link?: string;
/** 二维码 URL */
@prop()
qrCodeUrl?: string;
/** 过期时间 */
@prop()
expiresAt?: Date;
/** 点击次数 */
@prop({ default: 0 })
clickCount: number;
/** 扫码次数 */
@prop({ default: 0 })
scanCount: number;
/** 加入次数 */
@prop({ default: 0 })
joinCount: number;
/** 状态active / inactive */
@prop({ default: 'active' })
status: 'active' | 'inactive';
}
export type InviteDocument = db.DocumentType<Invite>;
const model = getModelForClass(Invite);
export type InviteModel = typeof model;
export default model;

View File

@@ -0,0 +1,40 @@
/**
* 踢人记录模型
* TypeGoose 定义,符合 Tailchat 插件标准模式
*/
import { db } from 'tailchat-server-sdk';
const { getModelForClass, prop, modelOptions, TimeStamps, index } = db;
@modelOptions({ options: { customName: 'p_saleschat_kickrecord' } })
@index({ groupId: 1, kickedAt: -1 })
@index({ userId: 1, kickedAt: -1 })
export class KickRecord extends TimeStamps implements db.Base {
_id: db.Types.ObjectId;
id: string;
/** 群组 ID关联群组 */
@prop({ required: true, ref: 'Group' })
groupId: db.Types.ObjectId;
/** 被踢用户 ID关联用户 */
@prop({ required: true, ref: 'User' })
userId: db.Types.ObjectId;
/** 操作人 ID关联用户 */
@prop({ required: true, ref: 'User' })
kickedBy: db.Types.ObjectId;
/** 原因 */
@prop({ default: '无' })
reason: string;
/** 踢出时间 */
@prop({ default: () => new Date() })
kickedAt: Date;
}
export type KickRecordDocument = db.DocumentType<KickRecord>;
const model = getModelForClass(KickRecord);
export type KickRecordModel = typeof model;
export default model;

View File

@@ -0,0 +1,47 @@
/**
* 统计模型
* TypeGoose 定义,符合 Tailchat 插件标准模式
*/
import { db } from 'tailchat-server-sdk';
const { getModelForClass, prop, modelOptions, TimeStamps, index } = db;
@modelOptions({ options: { customName: 'p_saleschat_stats' } })
@index({ salesId: 1, date: 1, period: 1 }, { unique: true })
export class Stats extends TimeStamps implements db.Base {
_id: db.Types.ObjectId;
id: string;
/** 销售 ID关联用户 */
@prop({ required: true, ref: 'User' })
salesId: db.Types.ObjectId;
/** 统计日期 */
@prop({ required: true })
date: Date;
/** 统计周期daily / weekly / monthly */
@prop({ required: true })
period: 'daily' | 'weekly' | 'monthly';
/** 创建的邀请数 */
@prop({ default: 0 })
invitesCreated: number;
/** 加入人数 */
@prop({ default: 0 })
joins: number;
/** 转化次数 */
@prop({ default: 0 })
conversions: number;
/** 收入 */
@prop({ default: 0 })
revenue: number;
}
export type StatsDocument = db.DocumentType<Stats>;
const model = getModelForClass(Stats);
export type StatsModel = typeof model;
export default model;

View File

@@ -0,0 +1,25 @@
{
"name": "tailchat-plugin-saleschat",
"version": "1.0.0",
"description": "销售邀请追踪系统 - Sales Chat Plugin for Tailchat",
"main": "services/index.js",
"author": "Sales Chat Team",
"license": "MIT",
"private": true,
"scripts": {
"build": "tsc",
"test": "jest"
},
"dependencies": {
"tailchat-server-sdk": "*",
"mongoose": "^8.0.0",
"qrcode": "^1.5.3"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/qrcode": "^1.5.5",
"typescript": "^5.0.0",
"jest": "^29.0.0",
"@types/jest": "^29.0.0"
}
}

View File

@@ -0,0 +1,663 @@
/**
* 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;