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:
2026-08-03 22:30:02 +08:00
parent ab10431001
commit 89fd333702
91 changed files with 1805 additions and 136 deletions
+2
View File
@@ -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',
+9 -42
View File
@@ -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>
);
}
+39 -2
View File
@@ -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>