import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { eventNamesForStoreLogCategory, resolveStoreLogCategory, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminStoreLogsQueryDto } from './dto/admin-query.dto'; type StoreLogItem = { id: string; source: 'analytics' | 'redeem_record' | 'store_payout'; storeId: string; storeAccountId: string | null; storeName: string | null; accountName: string | null; accountPhone: string | null; category: ReturnType; eventName: string; clientApp: string | null; refType: string | null; refId: string | null; extraJson: Record | null; createdAt: Date; }; @Injectable() export class AdminStoreLogsService { constructor(private readonly prisma: PrismaService) {} async list(query: AdminStoreLogsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const storeIds = await this.resolveStoreIds(query); if (storeIds && storeIds.length === 0) { return { items: [], total: 0, page, pageSize }; } const dateFilter = this.buildDateFilter(query); const categoryEvents = query.eventName ? [query.eventName] : query.category ? eventNamesForStoreLogCategory(query.category) : undefined; const includeRedeem = !query.category || query.category === 'redeem'; const includePayout = !query.category || query.category === 'payout'; const fetchLimit = page * pageSize; const [analyticsRows, redeemRows, payoutRows] = await Promise.all([ this.fetchAnalyticsRows({ storeIds, categoryEvents, dateFilter, limit: fetchLimit, }), includeRedeem ? this.fetchRedeemRows({ storeIds, dateFilter, limit: fetchLimit }) : Promise.resolve([]), includePayout ? this.fetchPayoutRows({ storeIds, dateFilter, limit: fetchLimit, category: query.category }) : Promise.resolve([]), ]); const merged = [...analyticsRows, ...redeemRows, ...payoutRows] .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); const items = merged.slice((page - 1) * pageSize, page * pageSize); const total = await this.countTotal({ storeIds, categoryEvents, dateFilter, includeRedeem, includePayout, }); return serializeBigInt({ items, total, page, pageSize }); } async detail(compositeId: string) { const [source, rawId] = compositeId.split(':'); if (!source || !rawId) throw new NotFoundException('日志不存在'); if (source === 'analytics') { const row = await this.prisma.logStoreAnalytics.findUnique({ where: { id: BigInt(rawId) } }); if (!row) throw new NotFoundException('日志不存在'); const enriched = await this.enrichAnalyticsRows([row]); return serializeBigInt(enriched[0]); } if (source === 'redeem_record') { const row = await this.prisma.redeemRecord.findUnique({ where: { id: BigInt(rawId) }, include: { store: true, user: { select: { id: true, userNo: true, phone: true, nickname: true } } }, }); if (!row) throw new NotFoundException('日志不存在'); return serializeBigInt(this.redeemToItem(row)); } if (source === 'store_payout') { const row = await this.prisma.storePayout.findUnique({ where: { id: BigInt(rawId) }, include: { store: true, redeemRecord: { select: { redeemNo: true, amount: true } } }, }); if (!row) throw new NotFoundException('日志不存在'); return serializeBigInt(this.payoutToItem(row)); } throw new NotFoundException('日志不存在'); } private buildDateFilter(query: AdminStoreLogsQueryDto): Prisma.DateTimeFilter | undefined { if (!query.from && !query.to) return undefined; return { ...(query.from ? { gte: new Date(query.from) } : {}), ...(query.to ? { lte: new Date(query.to) } : {}), }; } private async resolveStoreIds(query: AdminStoreLogsQueryDto): Promise { if (query.storeId) return [BigInt(query.storeId)]; const storeWhere: Prisma.StoreWhereInput = {}; if (query.storeName) storeWhere.name = { contains: query.storeName }; if (query.storeAccountId || query.phone) { const accountWhere: Prisma.StoreAccountWhereInput = {}; if (query.storeAccountId) accountWhere.id = BigInt(query.storeAccountId); if (query.phone) accountWhere.phone = { contains: query.phone }; const accounts = await this.prisma.storeAccount.findMany({ where: accountWhere, select: { bindings: { select: { storeId: true } }, }, take: 100, }); if (accounts.length === 0) return []; const ids = [...new Set(accounts.flatMap((a) => a.bindings.map((b) => b.storeId)))]; if (storeWhere.name) { const stores = await this.prisma.store.findMany({ where: { id: { in: ids }, ...storeWhere }, select: { id: true }, }); return stores.map((s) => s.id); } return ids; } if (query.storeName) { const stores = await this.prisma.store.findMany({ where: storeWhere, select: { id: true }, take: 100, }); return stores.map((s) => s.id); } return undefined; } private async fetchAnalyticsRows(input: { storeIds?: bigint[]; categoryEvents?: string[]; dateFilter?: Prisma.DateTimeFilter; limit: number; }) { const where: Prisma.LogStoreAnalyticsWhereInput = {}; if (input.storeIds) where.storeId = { in: input.storeIds }; if (input.categoryEvents?.length) where.eventName = { in: input.categoryEvents }; if (input.dateFilter) where.createdAt = input.dateFilter; const rows = await this.prisma.logStoreAnalytics.findMany({ where, orderBy: { createdAt: 'desc' }, take: input.limit, }); return this.enrichAnalyticsRows(rows); } private async enrichAnalyticsRows( rows: Array<{ id: bigint; storeAccountId: bigint | null; storeId: bigint; eventName: string; clientApp: string | null; refType: string | null; refId: bigint | null; extraJson: unknown; createdAt: Date; }>, ): Promise { const storeIds = [...new Set(rows.map((r) => r.storeId))]; const accountIds = [...new Set(rows.map((r) => r.storeAccountId).filter((id): id is bigint => id != null))]; const [stores, accounts] = await Promise.all([ storeIds.length ? this.prisma.store.findMany({ where: { id: { in: storeIds } }, select: { id: true, name: true } }) : Promise.resolve([]), accountIds.length ? this.prisma.storeAccount.findMany({ where: { id: { in: accountIds } }, select: { id: true, name: true, phone: true }, }) : Promise.resolve([]), ]); const storeMap = new Map(stores.map((s) => [s.id.toString(), s] as const)); const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const)); return rows.map((row) => { const store = storeMap.get(row.storeId.toString()); const account = row.storeAccountId ? accountMap.get(row.storeAccountId.toString()) : undefined; return { id: `analytics:${row.id}`, source: 'analytics' as const, storeId: row.storeId.toString(), storeAccountId: row.storeAccountId?.toString() ?? null, storeName: store?.name ?? null, accountName: account?.name ?? null, accountPhone: account?.phone ?? null, category: resolveStoreLogCategory(row.eventName), eventName: row.eventName, clientApp: row.clientApp, refType: row.refType, refId: row.refId?.toString() ?? null, extraJson: (row.extraJson as Record | null) ?? null, createdAt: row.createdAt, }; }); } private async fetchRedeemRows(input: { storeIds?: bigint[]; dateFilter?: Prisma.DateTimeFilter; limit: number; }) { const where: Prisma.RedeemRecordWhereInput = {}; if (input.storeIds) where.storeId = { in: input.storeIds }; if (input.dateFilter) where.createdAt = input.dateFilter; const rows = await this.prisma.redeemRecord.findMany({ where, orderBy: { createdAt: 'desc' }, take: input.limit, include: { store: { select: { id: true, name: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } }, }, }); return rows.map((row) => this.redeemToItem(row)); } private redeemToItem(row: { id: bigint; storeId: bigint; amount: unknown; redeemNo: string; createdAt: Date; store?: { name: string } | null; user?: { id: bigint; userNo: string | null; phone: string | null; nickname: string | null } | null; }): StoreLogItem { return { id: `redeem_record:${row.id}`, source: 'redeem_record', storeId: row.storeId.toString(), storeAccountId: null, storeName: row.store?.name ?? null, accountName: null, accountPhone: null, category: 'redeem', eventName: 'store_redeem_confirm', clientApp: 'SHOP_H5', refType: 'REDEEM_RECORD', refId: row.id.toString(), extraJson: { redeemNo: row.redeemNo, amount: Number(row.amount), userId: row.user?.id.toString(), userNo: row.user?.userNo, userPhone: row.user?.phone, userNickname: row.user?.nickname, legacy: true, }, createdAt: row.createdAt, }; } private async fetchPayoutRows(input: { storeIds?: bigint[]; dateFilter?: Prisma.DateTimeFilter; limit: number; category?: string; }) { const where: Prisma.StorePayoutWhereInput = {}; if (input.storeIds) where.storeId = { in: input.storeIds }; if (input.dateFilter) where.createdAt = input.dateFilter; if (input.category === 'payout') { // include all payout statuses } const rows = await this.prisma.storePayout.findMany({ where, orderBy: { createdAt: 'desc' }, take: input.limit, include: { store: { select: { id: true, name: true } }, redeemRecord: { select: { redeemNo: true, amount: true } }, }, }); return rows.map((row) => this.payoutToItem(row)); } private payoutToItem(row: { id: bigint; storeId: bigint; status: string; payoutAmount: unknown; redeemAmount: unknown; paidAt: Date | null; createdAt: Date; store?: { name: string } | null; redeemRecord?: { redeemNo: string; amount: unknown } | null; }): StoreLogItem { const paid = row.status === 'PAID'; return { id: `store_payout:${row.id}`, source: 'store_payout', storeId: row.storeId.toString(), storeAccountId: null, storeName: row.store?.name ?? null, accountName: null, accountPhone: null, category: 'payout', eventName: paid ? 'store_payout_paid' : 'store_payout_created', clientApp: paid ? 'HQ_WEB' : null, refType: 'STORE_PAYOUT', refId: row.id.toString(), extraJson: { status: row.status, payoutAmount: Number(row.payoutAmount), redeemAmount: Number(row.redeemAmount), redeemNo: row.redeemRecord?.redeemNo, paidAt: row.paidAt?.toISOString() ?? null, legacy: true, }, createdAt: paid && row.paidAt ? row.paidAt : row.createdAt, }; } private async countTotal(input: { storeIds?: bigint[]; categoryEvents?: string[]; dateFilter?: Prisma.DateTimeFilter; includeRedeem: boolean; includePayout: boolean; }) { const redeemWhere: Prisma.RedeemRecordWhereInput = {}; const payoutWhere: Prisma.StorePayoutWhereInput = {}; const analyticsWhere: Prisma.LogStoreAnalyticsWhereInput = {}; if (input.storeIds) { redeemWhere.storeId = { in: input.storeIds }; payoutWhere.storeId = { in: input.storeIds }; analyticsWhere.storeId = { in: input.storeIds }; } if (input.dateFilter) { redeemWhere.createdAt = input.dateFilter; payoutWhere.createdAt = input.dateFilter; analyticsWhere.createdAt = input.dateFilter; } if (input.categoryEvents?.length) analyticsWhere.eventName = { in: input.categoryEvents }; const [analyticsCount, redeemCount, payoutCount] = await Promise.all([ this.prisma.logStoreAnalytics.count({ where: analyticsWhere }), input.includeRedeem ? this.prisma.redeemRecord.count({ where: redeemWhere }) : 0, input.includePayout ? this.prisma.storePayout.count({ where: payoutWhere }) : 0, ]); return analyticsCount + redeemCount + payoutCount; } }