|
|
|
@@ -0,0 +1,381 @@
|
|
|
|
|
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<typeof resolveStoreLogCategory>;
|
|
|
|
|
eventName: string;
|
|
|
|
|
clientApp: string | null;
|
|
|
|
|
refType: string | null;
|
|
|
|
|
refId: string | null;
|
|
|
|
|
extraJson: Record<string, unknown> | 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<bigint[] | undefined> {
|
|
|
|
|
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: { storeId: true },
|
|
|
|
|
take: 100,
|
|
|
|
|
});
|
|
|
|
|
if (accounts.length === 0) return [];
|
|
|
|
|
const ids = [...new Set(accounts.map((a) => a.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<StoreLogItem[]> {
|
|
|
|
|
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<string, unknown> | 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;
|
|
|
|
|
}
|
|
|
|
|
}
|