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 { try { await this.ensureDefaults(); } catch (e) { this.logger.warn( `wecom report ensureDefaults failed: ${e instanceof Error ? e.message : String(e)}`, ); } } async ensureDefaults(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(), }; } }