企业微信智能机器人API插件
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
clampWecomPluginPage,
|
||||
clampWecomPluginPageSize,
|
||||
maskContactPhone,
|
||||
MOBILE_PHONE_RE,
|
||||
parseWecomPluginMetricsKind,
|
||||
toWecomPluginUserView,
|
||||
wecomPluginMetricsPeriod,
|
||||
type WecomReportStats,
|
||||
} from '@dukang/domain';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { PromoCodeService } from '../../modules/promo/promo-code.service';
|
||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||
import { WECOM_PLUGIN_AUDIT_BOT } from './wecom-plugin.constants';
|
||||
|
||||
function asNumber(v: Prisma.Decimal | number | null | undefined): number {
|
||||
if (v == null) return 0;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
function requireQuery(q?: string): string {
|
||||
const v = String(q ?? '').trim();
|
||||
if (!v) throw new BadRequestException('请提供查询关键词 q');
|
||||
return v;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WecomPluginQueryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly promo: PromoCodeService,
|
||||
private readonly audit: WecomBotAuditService,
|
||||
) {}
|
||||
|
||||
info() {
|
||||
return {
|
||||
name: '杜康好客运营查询',
|
||||
description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。',
|
||||
auth: { header: 'X-Api-Key' },
|
||||
tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'],
|
||||
};
|
||||
}
|
||||
|
||||
queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.order.read',
|
||||
permission: 'order.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: { orderNo: { contains: keyword } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
user: { select: { userNo: true, nickname: true, phone: true } },
|
||||
delivery: { select: { trackingNo: true, provider: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where: { orderNo: { contains: keyword } } }),
|
||||
]);
|
||||
return {
|
||||
total,
|
||||
items: items.map((o) => ({
|
||||
orderNo: o.orderNo,
|
||||
status: o.status,
|
||||
payStatus: o.payStatus,
|
||||
deliveryType: o.deliveryType,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
payAmount: asNumber(o.payAmount),
|
||||
user: toWecomPluginUserView({
|
||||
userNo: o.user?.userNo || '—',
|
||||
nickname: o.user?.nickname,
|
||||
phone: o.user?.phone,
|
||||
}),
|
||||
receiverName: o.receiverName,
|
||||
receiverPhone: maskContactPhone(o.receiverPhone),
|
||||
receiverCity: o.receiverCity,
|
||||
trackingNo: o.delivery?.trackingNo || null,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.user.read',
|
||||
permission: 'user.read',
|
||||
inputSummary: MOBILE_PHONE_RE.test(keyword) ? maskContactPhone(keyword) : keyword,
|
||||
},
|
||||
async () => {
|
||||
const where = MOBILE_PHONE_RE.test(keyword)
|
||||
? { phone: keyword, mergedIntoUserId: null }
|
||||
: { userNo: { contains: keyword }, mergedIntoUserId: null };
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
nickname: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
const balances = await Promise.all(
|
||||
rows.map((u) =>
|
||||
this.prisma.benefitCoupon.aggregate({
|
||||
where: { userId: u.id, status: 'ACTIVE' },
|
||||
_sum: { balance: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
total,
|
||||
items: rows.map((u, i) => ({
|
||||
...toWecomPluginUserView(u),
|
||||
status: u.status,
|
||||
orderCount: u._count.orders,
|
||||
benefitBalance: asNumber(balances[i]?._sum.balance),
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.store.read',
|
||||
permission: 'store.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const where = { name: { contains: keyword } };
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
name: true,
|
||||
status: true,
|
||||
cityName: true,
|
||||
district: true,
|
||||
address: true,
|
||||
contactPhone: true,
|
||||
phone: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.store.count({ where }),
|
||||
]);
|
||||
return {
|
||||
total,
|
||||
items: rows.map((s) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
cityName: s.cityName,
|
||||
district: s.district,
|
||||
address: s.address,
|
||||
contactPhone: maskContactPhone(s.contactPhone || s.phone),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.redeem.read',
|
||||
permission: 'redeem.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const byNo = await this.prisma.redeemRecord.findMany({
|
||||
where: { redeemNo: { contains: keyword } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: { store: { select: { name: true } } },
|
||||
});
|
||||
const rows =
|
||||
byNo.length > 0
|
||||
? byNo
|
||||
: await this.prisma.redeemRecord.findMany({
|
||||
where: { store: { name: { contains: keyword } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: { store: { select: { name: true } } },
|
||||
});
|
||||
const total =
|
||||
byNo.length > 0
|
||||
? await this.prisma.redeemRecord.count({ where: { redeemNo: { contains: keyword } } })
|
||||
: await this.prisma.redeemRecord.count({
|
||||
where: { store: { name: { contains: keyword } } },
|
||||
});
|
||||
return {
|
||||
total,
|
||||
items: rows.map((r) => ({
|
||||
redeemNo: r.redeemNo,
|
||||
amount: asNumber(r.amount),
|
||||
channel: r.channel,
|
||||
storeName: r.store?.name || '—',
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.promo.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const where = {
|
||||
OR: [
|
||||
{ code: { contains: keyword.toUpperCase() } },
|
||||
{ name: { contains: keyword } },
|
||||
],
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.commonPromoCode.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
scene: true,
|
||||
status: true,
|
||||
scanCount: true,
|
||||
orderCount: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.commonPromoCode.count({ where }),
|
||||
]);
|
||||
return { total, items: rows };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryPromoCodeStats(wecomUserId: string, code?: string) {
|
||||
const keyword = requireQuery(code);
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.promo.stats',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const row =
|
||||
(await this.prisma.commonPromoCode.findUnique({
|
||||
where: { code: keyword.toUpperCase() },
|
||||
select: { id: true, code: true, name: true, status: true },
|
||||
})) ||
|
||||
(await this.prisma.commonPromoCode.findFirst({
|
||||
where: { code: { contains: keyword.toUpperCase() } },
|
||||
select: { id: true, code: true, name: true, status: true },
|
||||
}));
|
||||
if (!row) throw new NotFoundException(`未找到推广码:${keyword}`);
|
||||
const stats = await this.promo.stats(row.id);
|
||||
return { code: row.code, name: row.name, status: row.status, stats };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryMetrics(wecomUserId: string, kindRaw?: string) {
|
||||
const kind = parseWecomPluginMetricsKind(kindRaw);
|
||||
if (!kind) {
|
||||
throw new BadRequestException('kind 须为 today | daily | weekly | monthly');
|
||||
}
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.metrics.read',
|
||||
inputSummary: kind,
|
||||
},
|
||||
async () => {
|
||||
const period = wecomPluginMetricsPeriod(kind);
|
||||
const stats = await this.loadStats(period.start, period.endExclusive);
|
||||
return {
|
||||
kind: period.kind,
|
||||
title: period.title,
|
||||
rangeLabel: period.rangeLabel,
|
||||
incrementLabel: period.incrementLabel,
|
||||
periodKey: period.periodKey,
|
||||
stats,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
|
||||
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
|
||||
const userBase = { status: 1, mergedIntoUserId: null } as const;
|
||||
const partnerBase = { isPrimary: 1 } as const;
|
||||
const paid = { payStatus: 'PAID' as const };
|
||||
|
||||
const [
|
||||
usersTotal,
|
||||
usersIncrement,
|
||||
partnersTotal,
|
||||
partnersIncrement,
|
||||
storesTotal,
|
||||
storesIncrement,
|
||||
ordersTotal,
|
||||
ordersIncrement,
|
||||
orderAmountTotal,
|
||||
orderAmountIncrement,
|
||||
redeemsTotal,
|
||||
redeemsIncrement,
|
||||
redeemAmountTotal,
|
||||
redeemAmountIncrement,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { ...userBase, createdAt: { lt: cutoff } } }),
|
||||
this.prisma.user.count({
|
||||
where: { ...userBase, createdAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
this.prisma.partnerAccount.count({
|
||||
where: { ...partnerBase, createdAt: { lt: cutoff } },
|
||||
}),
|
||||
this.prisma.partnerAccount.count({
|
||||
where: { ...partnerBase, createdAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
|
||||
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
|
||||
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||
this.prisma.order.aggregate({
|
||||
_sum: { payAmount: true },
|
||||
where: { ...paid, paidAt: { lt: cutoff } },
|
||||
}),
|
||||
this.prisma.order.aggregate({
|
||||
_sum: { payAmount: true },
|
||||
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||
this.prisma.redeemRecord.aggregate({
|
||||
_sum: { amount: true },
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
}),
|
||||
this.prisma.redeemRecord.aggregate({
|
||||
_sum: { amount: true },
|
||||
where: { createdAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
usersTotal,
|
||||
usersIncrement,
|
||||
partnersTotal,
|
||||
partnersIncrement,
|
||||
storesTotal,
|
||||
storesIncrement,
|
||||
ordersTotal,
|
||||
ordersIncrement,
|
||||
orderAmountTotal: asNumber(orderAmountTotal._sum.payAmount),
|
||||
orderAmountIncrement: asNumber(orderAmountIncrement._sum.payAmount),
|
||||
redeemsTotal,
|
||||
redeemsIncrement,
|
||||
redeemAmountTotal: asNumber(redeemAmountTotal._sum.amount),
|
||||
redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user