webadmin端增加商铺日志
This commit is contained in:
@@ -27,6 +27,7 @@ import TicketsPage from './pages/TicketsPage';
|
|||||||
import UserLogsPage from './pages/UserLogsPage';
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
import HqLogsPage from './pages/HqLogsPage';
|
import HqLogsPage from './pages/HqLogsPage';
|
||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
|
import StoreLogsPage from './pages/StoreLogsPage';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
if (!getToken()) return <Navigate to="/login" replace />;
|
if (!getToken()) return <Navigate to="/login" replace />;
|
||||||
@@ -65,6 +66,7 @@ export default function App() {
|
|||||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||||
|
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||||
<Route path="/logs/hq" element={<HqLogsPage />} />
|
<Route path="/logs/hq" element={<HqLogsPage />} />
|
||||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '日志',
|
label: '日志',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/logs/users', label: '用户日志' },
|
{ key: '/logs/users', label: '用户日志' },
|
||||||
|
{ key: '/logs/stores', label: '商户日志' },
|
||||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||||
{ key: '/logs/third-party', label: '第三方日志' },
|
{ key: '/logs/third-party', label: '第三方日志' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
STORE_LOG_CATEGORY_OPTIONS,
|
||||||
|
STORE_LOG_CATEGORY_LABELS,
|
||||||
|
resolveStoreLogCategory,
|
||||||
|
type StoreLogCategory,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<Row['source'], string> = {
|
||||||
|
analytics: '行为埋点',
|
||||||
|
redeem_record: '核销记录',
|
||||||
|
store_payout: '打款记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
function summarizeExtra(json: Record<string, unknown> | 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<Record<string, string>>(() => ({
|
||||||
|
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<Row>(
|
||||||
|
'/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<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue(filters);
|
||||||
|
}, [form, filters]);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '门店', width: 180,
|
||||||
|
render: (_, r) => (
|
||||||
|
<div>
|
||||||
|
<div>{r.storeName || '—'}</div>
|
||||||
|
<div style={{ color: '#999', fontSize: 12 }}>{r.storeId}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号', width: 150,
|
||||||
|
render: (_, r) => (
|
||||||
|
<div>
|
||||||
|
<div>{r.accountName || '—'}</div>
|
||||||
|
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.storeAccountId || '系统/HQ'}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分类', dataIndex: 'category', width: 100,
|
||||||
|
render: (v: StoreLogCategory | null, r) => (
|
||||||
|
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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) => (
|
||||||
|
<Button type="link" size="small" onClick={async () => {
|
||||||
|
const parsed = parseCompositeId(row.id);
|
||||||
|
if (!parsed) return;
|
||||||
|
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>详情</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||||
|
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Segmented
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
|
||||||
|
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);
|
||||||
|
}}>
|
||||||
|
<Form.Item name="storeId" label="门店ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
||||||
|
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||||
|
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||||
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
|
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">{String(detail.storeName || detail.storeId || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">
|
||||||
|
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="参数">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -61,6 +62,7 @@ type CityOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StoresPage() {
|
export default function StoresPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
@@ -321,6 +323,11 @@ export default function StoresPage() {
|
|||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="操作">
|
||||||
|
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||||
|
查看商户日志
|
||||||
|
</Button>
|
||||||
|
</Descriptions.Item>
|
||||||
{detail.coverUrl ? (
|
{detail.coverUrl ? (
|
||||||
<Descriptions.Item label="封面">
|
<Descriptions.Item label="封面">
|
||||||
<Image src={String(detail.coverUrl)} width={120} />
|
<Image src={String(detail.coverUrl)} width={120} />
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from './settlement';
|
|||||||
export * from './ops';
|
export * from './ops';
|
||||||
export * from './ticket';
|
export * from './ticket';
|
||||||
export * from './user-log';
|
export * from './user-log';
|
||||||
|
export * from './store-log';
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type StoreLogCategory =
|
||||||
|
| 'login'
|
||||||
|
| 'wechat_auth'
|
||||||
|
| 'redeem'
|
||||||
|
| 'payout'
|
||||||
|
| 'store_ops';
|
||||||
|
|
||||||
|
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
||||||
|
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<StoreLogCategory | '', string> = {
|
||||||
|
'': '全部',
|
||||||
|
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<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -603,4 +603,21 @@ CREATE TABLE log_user_analytics (
|
|||||||
KEY idx_log_user_analytics_ref (ref_type, ref_id)
|
KEY idx_log_user_analytics_ref (ref_type, ref_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为埋点日志';
|
) 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;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -872,3 +872,20 @@ model LogUserAnalytics {
|
|||||||
@@index([refType, refId])
|
@@index([refType, refId])
|
||||||
@@map("log_user_analytics")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export type TrackEventInput = {
|
|||||||
extraJson?: Record<string, unknown>;
|
extraJson?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TrackStoreEventInput = TrackEventInput & {
|
||||||
|
storeAccountId?: bigint;
|
||||||
|
storeId: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AnalyticsService {
|
export class AnalyticsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -42,6 +47,16 @@ export class AnalyticsService {
|
|||||||
void this.trackOne(userId, clientApp, event).catch(() => {});
|
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) {
|
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
@@ -53,4 +68,20 @@ export class AnalyticsService {
|
|||||||
extraJson: event.extraJson as never,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,23 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private trackStoreEvent(
|
||||||
|
storeAccountId: bigint | undefined,
|
||||||
|
storeId: bigint,
|
||||||
|
clientApp: ClientApp | string,
|
||||||
|
eventName: string,
|
||||||
|
extraJson?: Record<string, unknown>,
|
||||||
|
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) {
|
private async assertSmsSendAllowed(phone: string, scene: SmsScene) {
|
||||||
if (scene === SmsScene.STORE_LOGIN) {
|
if (scene === SmsScene.STORE_LOGIN) {
|
||||||
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||||
@@ -222,6 +239,19 @@ export class AuthService {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new BadRequestException(result.errorMessage ?? '短信发送失败');
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof BadRequestException) throw err;
|
if (err instanceof BadRequestException) throw err;
|
||||||
const message = err instanceof Error ? err.message : '短信发送失败';
|
const message = err instanceof Error ? err.message : '短信发送失败';
|
||||||
@@ -435,7 +465,18 @@ export class AuthService {
|
|||||||
|
|
||||||
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
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({
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
@@ -446,6 +487,12 @@ export class AuthService {
|
|||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { lastLoginAt: new Date() },
|
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, {
|
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||||
id: account.id.toString(),
|
id: account.id.toString(),
|
||||||
storeId: account.storeId.toString(),
|
storeId: account.storeId.toString(),
|
||||||
@@ -828,6 +875,12 @@ export class AuthService {
|
|||||||
include: { store: true },
|
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, {
|
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||||
id: account.id.toString(),
|
id: account.id.toString(),
|
||||||
storeId: account.storeId.toString(),
|
storeId: account.storeId.toString(),
|
||||||
@@ -872,6 +925,8 @@ export class AuthService {
|
|||||||
include: { store: true },
|
include: { store: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.trackStoreEvent(updated.id, updated.storeId, clientApp, 'store_wechat_bind', { platform });
|
||||||
|
|
||||||
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
|
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
|
||||||
id: updated.id.toString(),
|
id: updated.id.toString(),
|
||||||
storeId: updated.storeId.toString(),
|
storeId: updated.storeId.toString(),
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,6 +275,40 @@ export class AdminUserLogsQueryDto extends PaginationQueryDto {
|
|||||||
to?: string;
|
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 {
|
export class AdminHqLogsQueryDto extends PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { AdminProductsController } from './admin-products.controller';
|
|||||||
import { AdminProductsService } from './admin-products.service';
|
import { AdminProductsService } from './admin-products.service';
|
||||||
import { AdminUserLogsController } from './admin-user-logs.controller';
|
import { AdminUserLogsController } from './admin-user-logs.controller';
|
||||||
import { AdminUserLogsService } from './admin-user-logs.service';
|
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 { AdminHqLogsController } from './admin-hq-logs.controller';
|
||||||
import { AdminHqLogsService } from './admin-hq-logs.service';
|
import { AdminHqLogsService } from './admin-hq-logs.service';
|
||||||
import { AdminTicketsController } from './admin-tickets.controller';
|
import { AdminTicketsController } from './admin-tickets.controller';
|
||||||
@@ -58,6 +60,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
|||||||
AdminHqAccountsController,
|
AdminHqAccountsController,
|
||||||
AdminProductsController,
|
AdminProductsController,
|
||||||
AdminUserLogsController,
|
AdminUserLogsController,
|
||||||
|
AdminStoreLogsController,
|
||||||
AdminHqLogsController,
|
AdminHqLogsController,
|
||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
@@ -77,6 +80,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
|||||||
AdminHqAccountsService,
|
AdminHqAccountsService,
|
||||||
AdminProductsService,
|
AdminProductsService,
|
||||||
AdminUserLogsService,
|
AdminUserLogsService,
|
||||||
|
AdminStoreLogsService,
|
||||||
AdminHqLogsService,
|
AdminHqLogsService,
|
||||||
AdminTicketsService,
|
AdminTicketsService,
|
||||||
AdminXiaofeixiaService,
|
AdminXiaofeixiaService,
|
||||||
|
|||||||
@@ -161,6 +161,17 @@ export class RedeemService {
|
|||||||
|
|
||||||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
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({
|
return serializeBigInt({
|
||||||
token,
|
token,
|
||||||
amount: cached.amount,
|
amount: cached.amount,
|
||||||
@@ -288,6 +299,17 @@ export class RedeemService {
|
|||||||
storeId: account.storeId.toString(),
|
storeId: account.storeId.toString(),
|
||||||
amount,
|
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', {
|
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
|
||||||
eventName: 'benefit_redeem_success',
|
eventName: 'benefit_redeem_success',
|
||||||
refType: 'STORE',
|
refType: 'STORE',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
import {
|
import {
|
||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
} from './settlement.controller';
|
} from './settlement.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule],
|
imports: [IamModule, AnalyticsModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
SettlementController,
|
SettlementController,
|
||||||
PartnerMeController,
|
PartnerMeController,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
|
|
||||||
function generateBillNo() {
|
function generateBillNo() {
|
||||||
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||||
@@ -9,7 +10,10 @@ function generateBillNo() {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettlementService {
|
export class SettlementService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly analyticsService: AnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async createStorePayout(
|
async createStorePayout(
|
||||||
redeemRecordId: bigint,
|
redeemRecordId: bigint,
|
||||||
@@ -33,6 +37,18 @@ export class SettlementService {
|
|||||||
expectedPayAt,
|
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);
|
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);
|
return serializeBigInt(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
import { RedeemModule } from '../redeem/redeem.module';
|
import { RedeemModule } from '../redeem/redeem.module';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import {
|
import {
|
||||||
PartnerDashboardController,
|
PartnerDashboardController,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
} from './store.controller';
|
} from './store.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, forwardRef(() => RedeemModule)],
|
imports: [IamModule, AnalyticsModule, forwardRef(() => RedeemModule)],
|
||||||
controllers: [
|
controllers: [
|
||||||
PublicStoreController,
|
PublicStoreController,
|
||||||
PartnerStoreController,
|
PartnerStoreController,
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import { loadAppConfig } from '@dukang/shared-types';
|
|||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StoreService {
|
export class StoreService {
|
||||||
private readonly config = loadAppConfig();
|
private readonly config = loadAppConfig();
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly analyticsService: AnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async listOpenStores(cityCode?: string) {
|
async listOpenStores(cityCode?: string) {
|
||||||
const where: Record<string, unknown> = { status: 'OPEN' };
|
const where: Record<string, unknown> = { status: 'OPEN' };
|
||||||
@@ -213,6 +217,16 @@ export class StoreService {
|
|||||||
data: { status },
|
data: { status },
|
||||||
include: { coverResource: true },
|
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));
|
return serializeBigInt(mapStoreCompat(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,11 +281,22 @@ export class StoreService {
|
|||||||
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
|
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
|
||||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
|
include: { store: true },
|
||||||
});
|
});
|
||||||
|
const previousStatus = account.store.status;
|
||||||
const store = await this.prisma.store.update({
|
const store = await this.prisma.store.update({
|
||||||
where: { id: account.storeId },
|
where: { id: account.storeId },
|
||||||
data: { status },
|
data: { status },
|
||||||
});
|
});
|
||||||
|
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||||||
|
storeId: account.storeId,
|
||||||
|
eventName: 'store_status_change',
|
||||||
|
extraJson: {
|
||||||
|
status,
|
||||||
|
previousStatus,
|
||||||
|
actor: 'STORE',
|
||||||
|
},
|
||||||
|
});
|
||||||
return serializeBigInt(store);
|
return serializeBigInt(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user