优化
This commit is contained in:
49
server/plugins/com.msgbyte.saleschat/models/accesslog.ts
Normal file
49
server/plugins/com.msgbyte.saleschat/models/accesslog.ts
Normal 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;
|
||||
@@ -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;
|
||||
30
server/plugins/com.msgbyte.saleschat/models/index.ts
Normal file
30
server/plugins/com.msgbyte.saleschat/models/index.ts
Normal 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';
|
||||
62
server/plugins/com.msgbyte.saleschat/models/invite.ts
Normal file
62
server/plugins/com.msgbyte.saleschat/models/invite.ts
Normal 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;
|
||||
40
server/plugins/com.msgbyte.saleschat/models/kickrecord.ts
Normal file
40
server/plugins/com.msgbyte.saleschat/models/kickrecord.ts
Normal 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;
|
||||
47
server/plugins/com.msgbyte.saleschat/models/stats.ts
Normal file
47
server/plugins/com.msgbyte.saleschat/models/stats.ts
Normal 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;
|
||||
25
server/plugins/com.msgbyte.saleschat/package.json
Normal file
25
server/plugins/com.msgbyte.saleschat/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user