feat(analytics): persona logging upgrade and Sentry system config
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,6 +38,7 @@ import TicketsPage from './pages/TicketsPage';
|
||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||
import InvoicesPage from './pages/InvoicesPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import DomainEventsPage from './pages/DomainEventsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
import StoreLogsPage from './pages/StoreLogsPage';
|
||||
@@ -120,6 +121,7 @@ export default function App() {
|
||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||
<Route path="/logs/hq" element={<HqLogsPage />} />
|
||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||
|
||||
@@ -122,6 +122,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/logs/partners', label: '合伙人日志' },
|
||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||
{ key: '/logs/third-party', label: '第三方日志' },
|
||||
{ key: '/logs/domain-events', label: '领域事件' },
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
@@ -182,6 +183,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/logs/partners': 'logs',
|
||||
'/logs/hq': 'logs',
|
||||
'/logs/third-party': 'logs',
|
||||
'/logs/domain-events': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
export type UserLogCategory =
|
||||
| 'login'
|
||||
| 'browse_product'
|
||||
| 'order'
|
||||
| 'pay'
|
||||
| 'wechat_auth'
|
||||
| 'browse_store'
|
||||
| 'redeem'
|
||||
| 'profile';
|
||||
export {
|
||||
USER_LOG_CATEGORY_OPTIONS,
|
||||
resolveUserLogCategory,
|
||||
eventNamesForUserLogCategory,
|
||||
type UserLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
|
||||
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||
order: ['order_confirm_view', 'order_submit'],
|
||||
pay: ['pay_success', 'pay_fail'],
|
||||
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||
browse_store: ['store_list_view', 'store_detail_view'],
|
||||
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||
profile: ['profile_update', 'bind_phone'],
|
||||
};
|
||||
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
|
||||
|
||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'browse_product', label: '浏览商品' },
|
||||
{ value: 'order', label: '下单' },
|
||||
{ value: 'pay', label: '支付' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'browse_store', label: '浏览门店' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'profile', label: '信息修改' },
|
||||
];
|
||||
|
||||
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
|
||||
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
|
||||
[UserLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
|
||||
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
|
||||
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
|
||||
) as Record<string, string>;
|
||||
);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
status?: string | null;
|
||||
param1?: string | null;
|
||||
param2?: string | null;
|
||||
param3?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: '', label: '全部领域事件' },
|
||||
{ value: 'ORDER_STATUS', label: '订单状态' },
|
||||
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'TICKET_COLLAB', label: '工单协作' },
|
||||
{ value: 'PROMO_TOUCH', label: '推广触达' },
|
||||
];
|
||||
|
||||
export default function DomainEventsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const eventType = params.get('eventType') ?? '';
|
||||
const refType = params.get('refType') ?? '';
|
||||
const refId = params.get('refId') ?? '';
|
||||
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const q = new URLSearchParams();
|
||||
if (eventType) q.set('eventType', eventType);
|
||||
if (refType) q.set('refType', refType);
|
||||
if (refId) q.set('refId', refId);
|
||||
q.set('page', params.get('page') ?? '1');
|
||||
q.set('pageSize', '20');
|
||||
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
|
||||
`/admin/logs/domain-events?${q.toString()}`,
|
||||
)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, [eventType, refType, refId, params]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
|
||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<a
|
||||
onClick={async () => {
|
||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="领域事件">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={eventType}
|
||||
options={EVENT_OPTIONS}
|
||||
onChange={(v) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (v) next.set('eventType', v);
|
||||
else next.delete('eventType');
|
||||
next.set('page', '1');
|
||||
setParams(next);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: data?.page ?? 1,
|
||||
pageSize: data?.pageSize ?? 20,
|
||||
total: data?.total ?? 0,
|
||||
onChange: (p) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('page', String(p));
|
||||
setParams(next);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
|
||||
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
|
||||
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
|
||||
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
||||
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||
|
||||
@@ -30,6 +30,14 @@ type UserOrderRow = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type UserBehaviorLog = {
|
||||
id: string;
|
||||
eventName: string;
|
||||
clientApp?: string | null;
|
||||
createdAt: string;
|
||||
extraJson?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type UserDetail = AdminUserRow & {
|
||||
wxUnionId?: string | null;
|
||||
cityPref?: Record<string, unknown> | null;
|
||||
@@ -72,6 +80,7 @@ export default function UsersPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<UserDetail | null>(null);
|
||||
const [behaviorLogs, setBehaviorLogs] = useState<UserBehaviorLog[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('');
|
||||
@@ -123,8 +132,14 @@ export default function UsersPage() {
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const res = await request<UserDetail>(`/admin/users/${id}`);
|
||||
const [res, logs] = await Promise.all([
|
||||
request<UserDetail>(`/admin/users/${id}`),
|
||||
request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch(
|
||||
() => ({ items: [] as UserBehaviorLog[] }),
|
||||
),
|
||||
]);
|
||||
setDetail(res);
|
||||
setBehaviorLogs(logs.items ?? []);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
@@ -437,6 +452,28 @@ export default function UsersPage() {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
行为时间线(最近 {behaviorLogs.length} 条)
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ y: 200 }}
|
||||
dataSource={behaviorLogs}
|
||||
locale={{ emptyText: '暂无行为日志' }}
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v) => fmtTime(v) },
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName' },
|
||||
{ title: '端', dataIndex: 'clientApp', width: 90 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
全部订单({detail.orders?.length ?? 0})
|
||||
</Typography.Title>
|
||||
|
||||
Reference in New Issue
Block a user