diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 544947b..0e9ab9e 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -27,6 +27,7 @@ import TicketsPage from './pages/TicketsPage'; import UserLogsPage from './pages/UserLogsPage'; import HqLogsPage from './pages/HqLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; +import StoreLogsPage from './pages/StoreLogsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -65,6 +66,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 9285d0d..1ce7031 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -73,6 +73,7 @@ const MENU_ITEMS: MenuProps['items'] = [ label: '日志', children: [ { key: '/logs/users', label: '用户日志' }, + { key: '/logs/stores', label: '商户日志' }, { key: '/logs/hq', label: 'HQ 操作日志' }, { key: '/logs/third-party', label: '第三方日志' }, ], diff --git a/apps/admin-web/src/lib/store-log.ts b/apps/admin-web/src/lib/store-log.ts new file mode 100644 index 0000000..5703118 --- /dev/null +++ b/apps/admin-web/src/lib/store-log.ts @@ -0,0 +1,6 @@ +export { + STORE_LOG_CATEGORY_OPTIONS, + STORE_LOG_CATEGORY_LABELS, + resolveStoreLogCategory, + type StoreLogCategory, +} from '@dukang/shared-types'; diff --git a/apps/admin-web/src/pages/StoreLogsPage.tsx b/apps/admin-web/src/pages/StoreLogsPage.tsx new file mode 100644 index 0000000..5f9e193 --- /dev/null +++ b/apps/admin-web/src/pages/StoreLogsPage.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useSearchParams } from 'react-router-dom'; +import { + STORE_LOG_CATEGORY_OPTIONS, + STORE_LOG_CATEGORY_LABELS, + resolveStoreLogCategory, + type StoreLogCategory, +} from '../lib/store-log'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + source: 'analytics' | 'redeem_record' | 'store_payout'; + storeId: string; + storeAccountId: string | null; + storeName: string | null; + accountName: string | null; + accountPhone: string | null; + category: StoreLogCategory | null; + eventName: string; + clientApp: string | null; + refType: string | null; + refId: string | null; + extraJson: Record | null; + createdAt: string; +}; + +const SOURCE_LABELS: Record = { + analytics: '行为埋点', + redeem_record: '核销记录', + store_payout: '打款记录', +}; + +function summarizeExtra(json: Record | null) { + if (!json) return '—'; + const text = JSON.stringify(json); + return text.length > 80 ? `${text.slice(0, 80)}…` : text; +} + +function parseCompositeId(id: string) { + const idx = id.indexOf(':'); + if (idx <= 0) return null; + return { source: id.slice(0, idx), rawId: id.slice(idx + 1) }; +} + +export default function StoreLogsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const [form] = Form.useForm(); + const [category, setCategory] = useState(searchParams.get('category') ?? ''); + const [filters, setFilters] = useState>(() => ({ + storeId: searchParams.get('storeId') ?? '', + storeAccountId: searchParams.get('storeAccountId') ?? '', + phone: searchParams.get('phone') ?? '', + storeName: searchParams.get('storeName') ?? '', + eventName: searchParams.get('eventName') ?? '', + })); + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + '/admin/logs/stores', + () => { + const qs = new URLSearchParams(); + if (filters.storeId) qs.set('storeId', filters.storeId); + if (filters.storeAccountId) qs.set('storeAccountId', filters.storeAccountId); + if (filters.phone) qs.set('phone', filters.phone); + if (filters.storeName) qs.set('storeName', filters.storeName); + if (filters.eventName) qs.set('eventName', filters.eventName); + if (category) qs.set('category', category); + return qs; + }, + [filters, category], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + form.setFieldsValue(filters); + }, [form, filters]); + + const columns: ColumnsType = [ + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '门店', width: 180, + render: (_, r) => ( +
+
{r.storeName || '—'}
+
{r.storeId}
+
+ ), + }, + { + title: '账号', width: 150, + render: (_, r) => ( +
+
{r.accountName || '—'}
+
{r.accountPhone || r.storeAccountId || '系统/HQ'}
+
+ ), + }, + { + title: '分类', dataIndex: 'category', width: 100, + render: (v: StoreLogCategory | null, r) => ( + {STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'} + ), + }, + { title: '事件', dataIndex: 'eventName', width: 160 }, + { + title: '来源', dataIndex: 'source', width: 100, + render: (v: Row['source']) => SOURCE_LABELS[v] || v, + }, + { + title: '关联', width: 120, + render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), + }, + { + title: '摘要', ellipsis: true, + render: (_, r) => summarizeExtra(r.extraJson), + }, + { + title: '操作', width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ 商户日志 + + 门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。 + + ({ value: o.value, label: o.label }))} + value={category} + onChange={(v) => { + setCategory(String(v)); + setPage(1); + const next = new URLSearchParams(searchParams); + if (v) next.set('category', String(v)); + else next.delete('category'); + setSearchParams(next); + }} + /> +
{ + setFilters(v); + setPage(1); + const next = new URLSearchParams(searchParams); + for (const key of ['storeId', 'storeAccountId', 'phone', 'storeName', 'eventName'] as const) { + if (v[key]) next.set(key, v[key]); + else next.delete(key); + } + setSearchParams(next); + }}> + + + + + + + +
+ { setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + {String(detail.id)} + {String(detail.storeName || detail.storeId || '—')} + {String(detail.accountName || detail.storeAccountId || '系统/HQ')} + {String(detail.accountPhone || '—')} + + {(STORE_LOG_CATEGORY_LABELS as Record)[String(detail.category ?? '')] || String(detail.category || '—')} + + {String(detail.eventName)} + {SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')} + {String(detail.clientApp || '—')} + {detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'} + {fmtTime(String(detail.createdAt))} + +
+                {JSON.stringify(detail.extraJson ?? {}, null, 2)}
+              
+
+
+ )} +
+ + ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index eeddae2..caffa5e 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Alert, Button, @@ -61,6 +62,7 @@ type CityOption = { }; export default function StoresPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [editForm] = Form.useForm(); const [createForm] = Form.useForm(); @@ -321,6 +323,11 @@ export default function StoresPage() { {String(detail.id)} {String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)} {String(detail.redeemCount ?? 0)} + + + {detail.coverUrl ? ( diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 723aa6f..77b2035 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -10,3 +10,4 @@ export * from './settlement'; export * from './ops'; export * from './ticket'; export * from './user-log'; +export * from './store-log'; diff --git a/packages/shared-types/src/store-log.ts b/packages/shared-types/src/store-log.ts new file mode 100644 index 0000000..23c9e7a --- /dev/null +++ b/packages/shared-types/src/store-log.ts @@ -0,0 +1,63 @@ +export type StoreLogCategory = + | 'login' + | 'wechat_auth' + | 'redeem' + | 'payout' + | 'store_ops'; + +export const STORE_LOG_EVENT_CATEGORIES: Record = { + login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'], + wechat_auth: ['store_wechat_login', 'store_wechat_bind'], + redeem: ['store_redeem_preview', 'store_redeem_confirm'], + payout: ['store_payout_created', 'store_payout_paid'], + store_ops: ['store_status_change'], +}; + +export const STORE_LOG_CATEGORY_OPTIONS: Array<{ value: StoreLogCategory | ''; label: string }> = [ + { value: '', label: '全部' }, + { value: 'login', label: '登录' }, + { value: 'wechat_auth', label: '授权' }, + { value: 'redeem', label: '核销' }, + { value: 'payout', label: '提现/打款' }, + { value: 'store_ops', label: '门店操作' }, +]; + +export const STORE_LOG_CATEGORY_LABELS: Record = { + '': '全部', + login: '登录', + wechat_auth: '授权', + redeem: '核销', + payout: '提现/打款', + store_ops: '门店操作', +}; + +export function resolveStoreLogCategory(eventName: string): StoreLogCategory | null { + for (const [category, events] of Object.entries(STORE_LOG_EVENT_CATEGORIES) as Array< + [StoreLogCategory, readonly string[]] + >) { + if (events.includes(eventName)) return category; + } + return null; +} + +export function eventNamesForStoreLogCategory(category: string): string[] | undefined { + if (!category) return undefined; + return [...(STORE_LOG_EVENT_CATEGORIES[category as StoreLogCategory] ?? [])]; +} + +export interface StoreLogRowDto { + id: string; + source: 'analytics' | 'redeem_record' | 'store_payout'; + storeId: string; + storeAccountId: string | null; + storeName: string | null; + accountName: string | null; + accountPhone: string | null; + category: StoreLogCategory | null; + eventName: string; + clientApp: string | null; + refType: string | null; + refId: string | null; + extraJson: Record | null; + createdAt: string; +} diff --git a/server/dukang-api/prisma/init_v3.sql b/server/dukang-api/prisma/init_v3.sql index 0812a01..24af529 100644 --- a/server/dukang-api/prisma/init_v3.sql +++ b/server/dukang-api/prisma/init_v3.sql @@ -603,4 +603,21 @@ CREATE TABLE log_user_analytics ( KEY idx_log_user_analytics_ref (ref_type, ref_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为埋点日志'; +DROP TABLE IF EXISTS log_store_analytics; +CREATE TABLE log_store_analytics ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + store_account_id BIGINT UNSIGNED DEFAULT NULL COMMENT '门店账号ID,系统/HQ操作可为NULL', + store_id BIGINT UNSIGNED NOT NULL COMMENT '门店ID', + event_name VARCHAR(64) NOT NULL COMMENT '门店行为事件名', + client_app VARCHAR(32) DEFAULT NULL COMMENT 'SHOP_H5|HQ_WEB|PARTNER_H5', + ref_type VARCHAR(32) DEFAULT NULL COMMENT 'REDEEM_RECORD|STORE_PAYOUT|...', + ref_id BIGINT UNSIGNED DEFAULT NULL, + extra_json JSON DEFAULT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_log_store_analytics_store_created (store_id, created_at), + KEY idx_log_store_analytics_account_created (store_account_id, created_at), + KEY idx_log_store_analytics_event_created (event_name, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店商户行为日志'; + SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 74e4feb..d0cd3fa 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -872,3 +872,20 @@ model LogUserAnalytics { @@index([refType, refId]) @@map("log_user_analytics") } + +model LogStoreAnalytics { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeAccountId BigInt? @map("store_account_id") @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + eventName String @map("event_name") @db.VarChar(64) + clientApp ClientApp? @map("client_app") + refType String? @map("ref_type") @db.VarChar(32) + refId BigInt? @map("ref_id") @db.UnsignedBigInt + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@index([storeId, createdAt]) + @@index([storeAccountId, createdAt]) + @@index([eventName, createdAt]) + @@map("log_store_analytics") +} diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts index 6a66535..b70edec 100644 --- a/server/dukang-api/src/modules/analytics/analytics.service.ts +++ b/server/dukang-api/src/modules/analytics/analytics.service.ts @@ -10,6 +10,11 @@ export type TrackEventInput = { extraJson?: Record; }; +export type TrackStoreEventInput = TrackEventInput & { + storeAccountId?: bigint; + storeId: bigint; +}; + @Injectable() export class AnalyticsService { constructor(private readonly prisma: PrismaService) {} @@ -42,6 +47,16 @@ export class AnalyticsService { void this.trackOne(userId, clientApp, event).catch(() => {}); } + async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) { + await this.prisma.logStoreAnalytics.create({ + data: this.toStoreRow(storeAccountId, clientApp, event), + }); + } + + trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) { + void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {}); + } + private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { return { userId, @@ -53,4 +68,20 @@ export class AnalyticsService { extraJson: event.extraJson as never, }; } + + private toStoreRow( + storeAccountId: bigint | undefined, + clientApp: ClientApp | string, + event: TrackStoreEventInput, + ) { + return { + storeAccountId, + storeId: event.storeId, + eventName: event.eventName, + clientApp: clientApp as ClientApp, + refType: event.refType, + refId: event.refId, + extraJson: event.extraJson as never, + }; + } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 361a4b3..c4e7f0a 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -150,6 +150,23 @@ export class AuthService { }); } + private trackStoreEvent( + storeAccountId: bigint | undefined, + storeId: bigint, + clientApp: ClientApp | string, + eventName: string, + extraJson?: Record, + ref?: { refType?: string; refId?: bigint }, + ) { + this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, { + storeId, + eventName, + refType: ref?.refType, + refId: ref?.refId, + extraJson, + }); + } + private async assertSmsSendAllowed(phone: string, scene: SmsScene) { if (scene === SmsScene.STORE_LOGIN) { const account = await this.prisma.storeAccount.findUnique({ where: { phone } }); @@ -222,6 +239,19 @@ export class AuthService { if (!result.ok) { throw new BadRequestException(result.errorMessage ?? '短信发送失败'); } + if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') { + const storeAccount = await this.prisma.storeAccount.findUnique({ + where: { id: actorRef.refId }, + select: { id: true, storeId: true }, + }); + if (storeAccount) { + this.trackStoreEvent(storeAccount.id, storeAccount.storeId, clientApp, 'store_sms_send', { + scene, + phone: this.maskPhone(normalizedPhone), + status: 'success', + }); + } + } } catch (err) { if (err instanceof BadRequestException) throw err; const message = err instanceof Error ? err.message : '短信发送失败'; @@ -435,7 +465,18 @@ export class AuthService { async loginStore(phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); - await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN); + try { + await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN); + } catch (err) { + const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } }); + if (account) { + this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_verify_fail', { + phone: this.maskPhone(normalizedPhone), + reason: err instanceof BadRequestException ? err.message : '验证码错误', + }); + } + throw err; + } const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, include: { store: true }, @@ -446,6 +487,12 @@ export class AuthService { where: { id: account.id }, data: { lastLoginAt: new Date() }, }); + this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_login', { + phone: this.maskPhone(normalizedPhone), + }); + this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', { + method: 'sms', + }); return this.issueToken('STORE', account.id, clientApp, false, undefined, { id: account.id.toString(), storeId: account.storeId.toString(), @@ -828,6 +875,12 @@ export class AuthService { include: { store: true }, }); + this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_wechat_login', { platform }); + this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', { + method: 'wechat', + platform, + }); + return this.issueToken('STORE', account.id, clientApp, false, undefined, { id: account.id.toString(), storeId: account.storeId.toString(), @@ -872,6 +925,8 @@ export class AuthService { include: { store: true }, }); + this.trackStoreEvent(updated.id, updated.storeId, clientApp, 'store_wechat_bind', { platform }); + return this.issueToken('STORE', updated.id, clientApp, false, undefined, { id: updated.id.toString(), storeId: updated.storeId.toString(), diff --git a/server/dukang-api/src/modules/ops/admin-store-logs.controller.ts b/server/dukang-api/src/modules/ops/admin-store-logs.controller.ts new file mode 100644 index 0000000..61d73a6 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-store-logs.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { AdminStoreLogsService } from './admin-store-logs.service'; +import { AdminStoreLogsQueryDto } from './dto/admin-query.dto'; + +@Controller('admin/logs/stores') +@UseGuards(HqAuthGuard) +export class AdminStoreLogsController { + constructor(private readonly service: AdminStoreLogsService) {} + + @Get() + list(@Query() query: AdminStoreLogsQueryDto) { + return this.service.list(query); + } + + @Get(':source/:rawId') + detail(@Param('source') source: string, @Param('rawId') rawId: string) { + return this.service.detail(`${source}:${rawId}`); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-store-logs.service.ts b/server/dukang-api/src/modules/ops/admin-store-logs.service.ts new file mode 100644 index 0000000..94c0f69 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-store-logs.service.ts @@ -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; + 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: { 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 { + 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; + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 2f6f833..d55ba38 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -275,6 +275,40 @@ export class AdminUserLogsQueryDto extends PaginationQueryDto { to?: string; } +export class AdminStoreLogsQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + storeId?: string; + + @IsOptional() + @IsString() + storeAccountId?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + storeName?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + eventName?: string; + + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; +} + export class AdminHqLogsQueryDto extends PaginationQueryDto { @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 138a0a0..5b229f2 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -23,6 +23,8 @@ import { AdminProductsController } from './admin-products.controller'; import { AdminProductsService } from './admin-products.service'; import { AdminUserLogsController } from './admin-user-logs.controller'; import { AdminUserLogsService } from './admin-user-logs.service'; +import { AdminStoreLogsController } from './admin-store-logs.controller'; +import { AdminStoreLogsService } from './admin-store-logs.service'; import { AdminHqLogsController } from './admin-hq-logs.controller'; import { AdminHqLogsService } from './admin-hq-logs.service'; import { AdminTicketsController } from './admin-tickets.controller'; @@ -58,6 +60,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service'; AdminHqAccountsController, AdminProductsController, AdminUserLogsController, + AdminStoreLogsController, AdminHqLogsController, AdminTicketsController, AdminXiaofeixiaController, @@ -77,6 +80,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service'; AdminHqAccountsService, AdminProductsService, AdminUserLogsService, + AdminStoreLogsService, AdminHqLogsService, AdminTicketsService, AdminXiaofeixiaService, diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 780c547..5e7d117 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -161,6 +161,17 @@ export class RedeemService { const ttl = await this.redis.ttl(`redeem:token:${token}`); + this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { + storeId: account.storeId, + eventName: 'store_redeem_preview', + extraJson: { + tokenSuffix: token.slice(-8), + amount: cached.amount, + userId: cached.userId, + redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT', + }, + }); + return serializeBigInt({ token, amount: cached.amount, @@ -288,6 +299,17 @@ export class RedeemService { storeId: account.storeId.toString(), amount, }; + this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { + storeId: account.storeId, + eventName: 'store_redeem_confirm', + refType: 'REDEEM_RECORD', + refId: record.id, + extraJson: { + redeemNo: record.redeemNo, + amount, + userId: cached.userId, + }, + }); this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { eventName: 'benefit_redeem_success', refType: 'STORE', diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts index 5638f10..ec7c869 100644 --- a/server/dukang-api/src/modules/settlement/settlement.module.ts +++ b/server/dukang-api/src/modules/settlement/settlement.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { IamModule } from '../iam/iam.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; import { SettlementService } from './settlement.service'; import { AdminPartnerBillController, @@ -10,7 +11,7 @@ import { } from './settlement.controller'; @Module({ - imports: [IamModule], + imports: [IamModule, AnalyticsModule], controllers: [ SettlementController, PartnerMeController, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 80fe196..219d25c 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { AnalyticsService } from '../analytics/analytics.service'; function generateBillNo() { return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; @@ -9,7 +10,10 @@ function generateBillNo() { @Injectable() export class SettlementService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly analyticsService: AnalyticsService, + ) {} async createStorePayout( redeemRecordId: bigint, @@ -33,6 +37,18 @@ export class SettlementService { expectedPayAt, }, }); + this.analyticsService.trackStoreOneSafe(undefined, 'SHOP_H5', { + storeId, + eventName: 'store_payout_created', + refType: 'STORE_PAYOUT', + refId: payout.id, + extraJson: { + redeemRecordId: redeemRecordId.toString(), + payoutAmount, + redeemAmount, + settlementRate, + }, + }); return serializeBigInt(payout); } @@ -108,6 +124,18 @@ export class SettlementService { }, }); + this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', { + storeId: payout.storeId, + eventName: 'store_payout_paid', + refType: 'STORE_PAYOUT', + refId: id, + extraJson: { + batchNo: dto.batchNo ?? payout.batchNo, + paymentRef: dto.paymentRef, + payoutAmount: Number(payout.payoutAmount), + }, + }); + return serializeBigInt(updated); } diff --git a/server/dukang-api/src/modules/store/store.module.ts b/server/dukang-api/src/modules/store/store.module.ts index f766576..2971de1 100644 --- a/server/dukang-api/src/modules/store/store.module.ts +++ b/server/dukang-api/src/modules/store/store.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { IamModule } from '../iam/iam.module'; import { RedeemModule } from '../redeem/redeem.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; import { StoreService } from './store.service'; import { PartnerDashboardController, @@ -11,7 +12,7 @@ import { } from './store.controller'; @Module({ - imports: [IamModule, forwardRef(() => RedeemModule)], + imports: [IamModule, AnalyticsModule, forwardRef(() => RedeemModule)], controllers: [ PublicStoreController, PartnerStoreController, diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 2647b91..a8fd59d 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -7,12 +7,16 @@ import { loadAppConfig } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { mapStoreCompat } from '../../common/compat/v31-compat'; +import { AnalyticsService } from '../analytics/analytics.service'; @Injectable() export class StoreService { private readonly config = loadAppConfig(); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly analyticsService: AnalyticsService, + ) {} async listOpenStores(cityCode?: string) { const where: Record = { status: 'OPEN' }; @@ -213,6 +217,16 @@ export class StoreService { data: { status }, include: { coverResource: true }, }); + this.analyticsService.trackStoreOneSafe(undefined, 'PARTNER_H5', { + storeId, + eventName: 'store_status_change', + extraJson: { + status, + previousStatus: store.status, + actor: 'PARTNER', + partnerAccountId: partnerAccountId.toString(), + }, + }); return serializeBigInt(mapStoreCompat(updated)); } @@ -267,11 +281,22 @@ export class StoreService { async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') { const account = await this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, + include: { store: true }, }); + const previousStatus = account.store.status; const store = await this.prisma.store.update({ where: { id: account.storeId }, data: { status }, }); + this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { + storeId: account.storeId, + eventName: 'store_status_change', + extraJson: { + status, + previousStatus, + actor: 'STORE', + }, + }); return serializeBigInt(store); }