@@ -0,0 +1,381 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
formatWecomReportMarkdown,
|
||||
isWecomReportKind,
|
||||
wecomReportCutoff,
|
||||
wecomReportPeriod,
|
||||
wecomReportShouldFire,
|
||||
type WecomReportKind,
|
||||
type WecomReportStats,
|
||||
} from '@dukang/domain';
|
||||
import {
|
||||
maskWecomWebhookUrl,
|
||||
WECOM_REPORT_KIND_LABELS,
|
||||
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||
type UpdateWecomReportPushRequest,
|
||||
type WecomReportPreviewDto,
|
||||
type WecomReportPushDto,
|
||||
type WecomReportSendResultDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
|
||||
const KIND_SEED: Array<{
|
||||
kind: WecomReportKind;
|
||||
name: string;
|
||||
sendHour: number;
|
||||
sendMinute: number;
|
||||
}> = [
|
||||
{ kind: 'daily', name: '经营日报', sendHour: 20, sendMinute: 0 },
|
||||
{ kind: 'weekly', name: '经营周报', sendHour: 9, sendMinute: 0 },
|
||||
{ kind: 'monthly', name: '经营月报', sendHour: 9, sendMinute: 0 },
|
||||
];
|
||||
|
||||
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 clampHour(n: number | undefined, fallback: number): number {
|
||||
if (n == null || !Number.isFinite(n)) return fallback;
|
||||
return Math.min(23, Math.max(0, Math.floor(n)));
|
||||
}
|
||||
|
||||
function clampMinute(n: number | undefined, fallback: number): number {
|
||||
if (n == null || !Number.isFinite(n)) return fallback;
|
||||
return Math.min(59, Math.max(0, Math.floor(n)));
|
||||
}
|
||||
|
||||
type ReportRow = {
|
||||
id: bigint;
|
||||
kind: string;
|
||||
name: string;
|
||||
webhookUrl: string;
|
||||
enabled: boolean | number;
|
||||
mentionWecomUserId: string | null;
|
||||
sendHour: number;
|
||||
sendMinute: number;
|
||||
sendWeekday: number;
|
||||
sendMonthDay: number;
|
||||
lastSentPeriod: string | null;
|
||||
lastSentAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function asBool(v: boolean | number): boolean {
|
||||
return v === true || v === 1;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminWecomReportsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AdminWecomReportsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.ensureDefaults();
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`wecom report ensureDefaults failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureDefaults(): Promise<void> {
|
||||
for (const seed of KIND_SEED) {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT IGNORE INTO wecom_report_push
|
||||
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
|
||||
VALUES
|
||||
(${seed.kind}, ${seed.name}, ${WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK}, 0, ${seed.sendHour}, ${seed.sendMinute}, 1, 1)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private async findByKind(kind: string): Promise<ReportRow | null> {
|
||||
const rows = await this.prisma.$queryRaw<ReportRow[]>`
|
||||
SELECT id, kind, name,
|
||||
webhook_url AS webhookUrl, enabled,
|
||||
mention_wecom_user_id AS mentionWecomUserId,
|
||||
send_hour AS sendHour, send_minute AS sendMinute,
|
||||
send_weekday AS sendWeekday, send_month_day AS sendMonthDay,
|
||||
last_sent_period AS lastSentPeriod, last_sent_at AS lastSentAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM wecom_report_push WHERE kind = ${kind} LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findAll(): Promise<ReportRow[]> {
|
||||
return this.prisma.$queryRaw<ReportRow[]>`
|
||||
SELECT id, kind, name,
|
||||
webhook_url AS webhookUrl, enabled,
|
||||
mention_wecom_user_id AS mentionWecomUserId,
|
||||
send_hour AS sendHour, send_minute AS sendMinute,
|
||||
send_weekday AS sendWeekday, send_month_day AS sendMonthDay,
|
||||
last_sent_period AS lastSentPeriod, last_sent_at AS lastSentAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM wecom_report_push
|
||||
`;
|
||||
}
|
||||
|
||||
parseKind(raw: string): WecomReportKind {
|
||||
if (!isWecomReportKind(raw)) {
|
||||
throw new BadRequestException('报告类型须为 daily / weekly / monthly');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async list(): Promise<WecomReportPushDto[]> {
|
||||
await this.ensureDefaults();
|
||||
const rows = await this.findAll();
|
||||
const byKind = new Map(rows.map((r) => [r.kind, r]));
|
||||
return KIND_SEED.map((s) => {
|
||||
const row = byKind.get(s.kind);
|
||||
if (!row) throw new NotFoundException(`${WECOM_REPORT_KIND_LABELS[s.kind]}未初始化`);
|
||||
return this.toDto(row);
|
||||
});
|
||||
}
|
||||
|
||||
async detail(kind: WecomReportKind): Promise<WecomReportPushDto> {
|
||||
await this.ensureDefaults();
|
||||
const row = await this.findByKind(kind);
|
||||
if (!row) throw new NotFoundException('报告配置不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(kind: WecomReportKind, dto: UpdateWecomReportPushRequest): Promise<WecomReportPushDto> {
|
||||
await this.ensureDefaults();
|
||||
const existing = await this.findByKind(kind);
|
||||
if (!existing) throw new NotFoundException('报告配置不存在');
|
||||
|
||||
const webhookUrl =
|
||||
dto.webhookUrl !== undefined ? dto.webhookUrl.trim() : existing.webhookUrl;
|
||||
if (!webhookUrl) throw new BadRequestException('请填写 Webhook URL');
|
||||
|
||||
const name = dto.name !== undefined ? dto.name.trim() || existing.name : existing.name;
|
||||
const enabled = dto.enabled !== undefined ? (dto.enabled ? 1 : 0) : asBool(existing.enabled) ? 1 : 0;
|
||||
const mention =
|
||||
dto.mentionWecomUserId === undefined
|
||||
? existing.mentionWecomUserId
|
||||
: dto.mentionWecomUserId?.trim() || null;
|
||||
const sendHour = dto.sendHour !== undefined ? clampHour(dto.sendHour, existing.sendHour) : existing.sendHour;
|
||||
const sendMinute =
|
||||
dto.sendMinute !== undefined ? clampMinute(dto.sendMinute, existing.sendMinute) : existing.sendMinute;
|
||||
const sendWeekday =
|
||||
dto.sendWeekday !== undefined
|
||||
? Math.min(7, Math.max(1, Math.floor(dto.sendWeekday) || 1))
|
||||
: existing.sendWeekday;
|
||||
const sendMonthDay =
|
||||
dto.sendMonthDay !== undefined
|
||||
? Math.min(31, Math.max(1, Math.floor(dto.sendMonthDay) || 1))
|
||||
: existing.sendMonthDay;
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE wecom_report_push SET
|
||||
name = ${name},
|
||||
webhook_url = ${webhookUrl},
|
||||
enabled = ${enabled},
|
||||
mention_wecom_user_id = ${mention},
|
||||
send_hour = ${sendHour},
|
||||
send_minute = ${sendMinute},
|
||||
send_weekday = ${sendWeekday},
|
||||
send_month_day = ${sendMonthDay}
|
||||
WHERE kind = ${kind}
|
||||
`;
|
||||
const row = await this.findByKind(kind);
|
||||
if (!row) throw new NotFoundException('报告配置不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async preview(kind: WecomReportKind): Promise<WecomReportPreviewDto> {
|
||||
const period = wecomReportPeriod(kind);
|
||||
const stats = await this.loadStats(period.start, wecomReportCutoff(period));
|
||||
return {
|
||||
kind,
|
||||
periodKey: period.periodKey,
|
||||
title: period.title,
|
||||
rangeLabel: period.rangeLabel,
|
||||
markdown: formatWecomReportMarkdown(period, stats),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
async send(kind: WecomReportKind, opts?: { markSent?: boolean }): Promise<WecomReportSendResultDto> {
|
||||
await this.ensureDefaults();
|
||||
const row = await this.findByKind(kind);
|
||||
if (!row) throw new NotFoundException('报告配置不存在');
|
||||
const url = row.webhookUrl.trim();
|
||||
if (!url || url.includes('key=PENDING')) {
|
||||
throw new BadRequestException('请先填写有效的企微群机器人 Webhook');
|
||||
}
|
||||
|
||||
const period = wecomReportPeriod(kind);
|
||||
const stats = await this.loadStats(period.start, wecomReportCutoff(period));
|
||||
let content = formatWecomReportMarkdown(period, stats);
|
||||
if (row.mentionWecomUserId) {
|
||||
content = applyWecomAtMentionInContent(content, row.mentionWecomUserId);
|
||||
}
|
||||
const ok = await this.wecomPush.sendMarkdownToWebhook(url, content);
|
||||
if (!ok) {
|
||||
throw new BadRequestException('Webhook 发送失败,请检查地址或群机器人是否可用');
|
||||
}
|
||||
if (opts?.markSent !== false) {
|
||||
const sentAt = new Date();
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE wecom_report_push
|
||||
SET last_sent_period = ${period.periodKey}, last_sent_at = ${sentAt}
|
||||
WHERE kind = ${kind}
|
||||
`;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: `已发送${WECOM_REPORT_KIND_LABELS[kind]}`,
|
||||
periodKey: period.periodKey,
|
||||
};
|
||||
}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Shanghai' })
|
||||
async tickScheduled(): Promise<void> {
|
||||
try {
|
||||
await this.ensureDefaults();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const rows = await this.findAll();
|
||||
for (const row of rows) {
|
||||
if (!asBool(row.enabled)) continue;
|
||||
if (!isWecomReportKind(row.kind)) continue;
|
||||
const due = wecomReportShouldFire(
|
||||
row.kind,
|
||||
{
|
||||
enabled: asBool(row.enabled),
|
||||
sendHour: row.sendHour,
|
||||
sendMinute: row.sendMinute,
|
||||
sendWeekday: row.sendWeekday,
|
||||
sendMonthDay: row.sendMonthDay,
|
||||
lastSentPeriod: row.lastSentPeriod,
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (!due) continue;
|
||||
try {
|
||||
await this.send(row.kind);
|
||||
this.logger.log(`sent wecom ${row.kind} report period=${wecomReportPeriod(row.kind, now).periodKey}`);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`wecom ${row.kind} report send failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: ReportRow): WecomReportPushDto {
|
||||
return {
|
||||
id: String(row.id),
|
||||
kind: row.kind as WecomReportKind,
|
||||
name: row.name,
|
||||
webhookUrl: row.webhookUrl,
|
||||
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
|
||||
enabled: asBool(row.enabled),
|
||||
mentionWecomUserId: row.mentionWecomUserId,
|
||||
sendHour: row.sendHour,
|
||||
sendMinute: row.sendMinute,
|
||||
sendWeekday: row.sendWeekday,
|
||||
sendMonthDay: row.sendMonthDay,
|
||||
lastSentPeriod: row.lastSentPeriod,
|
||||
lastSentAt: row.lastSentAt ? row.lastSentAt.toISOString() : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user