Files
dukang/server/dukang-api/src/modules/ops/admin-wecom-reports.service.ts
T
jacy e5d7e468cc fix(ops): 企微报告保存改走 Prisma Client
空 @成员 会把 null 插进 tagged raw SQL,MySQL 下 Prisma 报 Code N/A。
2026-09-02 22:15:07 +08:00

341 lines
11 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma, type WecomReportPush } 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 = WecomReportPush;
@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.wecomReportPush.upsert({
where: { kind: seed.kind },
create: {
kind: seed.kind,
name: seed.name,
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
enabled: false,
sendHour: seed.sendHour,
sendMinute: seed.sendMinute,
sendWeekday: 1,
sendMonthDay: 1,
},
update: {},
});
}
}
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.prisma.wecomReportPush.findMany();
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.prisma.wecomReportPush.findUnique({ where: { 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.prisma.wecomReportPush.findUnique({ where: { 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 : existing.enabled;
const mentionWecomUserId =
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;
const row = await this.prisma.wecomReportPush.update({
where: { kind },
data: {
name,
webhookUrl,
enabled,
mentionWecomUserId,
sendHour,
sendMinute,
sendWeekday,
sendMonthDay,
},
});
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.prisma.wecomReportPush.findUnique({ where: { 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) {
await this.prisma.wecomReportPush.update({
where: { kind },
data: { lastSentPeriod: period.periodKey, lastSentAt: new Date() },
});
}
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.prisma.wecomReportPush.findMany();
for (const row of rows) {
if (!row.enabled) continue;
if (!isWecomReportKind(row.kind)) continue;
const due = wecomReportShouldFire(
row.kind,
{
enabled: 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: 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(),
};
}
}