diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 49051bd..da8497b 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index b9a8d12..c7d0fac 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -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: , 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',
diff --git a/apps/admin-web/src/lib/user-log.ts b/apps/admin-web/src/lib/user-log.ts
index 9114728..3a8c6e9 100644
--- a/apps/admin-web/src/lib/user-log.ts
+++ b/apps/admin-web/src/lib/user-log.ts
@@ -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 = {
- 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 = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
-) as Record;
+);
diff --git a/apps/admin-web/src/pages/DomainEventsPage.tsx b/apps/admin-web/src/pages/DomainEventsPage.tsx
new file mode 100644
index 0000000..4e663cf
--- /dev/null
+++ b/apps/admin-web/src/pages/DomainEventsPage.tsx
@@ -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(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 = [
+ { title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
+ { title: '类型', dataIndex: 'eventType', width: 130, render: (v) => {v} },
+ { title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
+ { title: '状态', dataIndex: 'status', width: 100 },
+ { title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
+ {
+ title: '操作',
+ width: 80,
+ render: (_, r) => (
+ {
+ setDetail(await request(`/admin/logs/domain-events/${r.id}`));
+ }}
+ >
+ 详情
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+ {
+ const next = new URLSearchParams(params);
+ next.set('page', String(p));
+ setParams(next);
+ },
+ }}
+ />
+ setDetail(null)}>
+ {detail && (
+
+ {detail.id}
+ {detail.eventType}
+ {`${detail.refType} #${detail.refId}`}
+ {detail.param1}
+ {detail.param2}
+ {detail.param3}
+ {detail.remark}
+ {fmtTime(detail.createdAt)}
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx
index 18661b6..9d1e458 100644
--- a/apps/admin-web/src/pages/UsersPage.tsx
+++ b/apps/admin-web/src/pages/UsersPage.tsx
@@ -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 | null;
+};
+
type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record | null;
@@ -72,6 +80,7 @@ export default function UsersPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState(null);
+ const [behaviorLogs, setBehaviorLogs] = useState([]);
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(`/admin/users/${id}`);
+ const [res, logs] = await Promise.all([
+ request(`/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() {
+
+ 行为时间线(最近 {behaviorLogs.length} 条)
+
+ fmtTime(v) },
+ {
+ title: '分类',
+ width: 100,
+ render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
+ },
+ { title: '事件', dataIndex: 'eventName' },
+ { title: '端', dataIndex: 'clientApp', width: 90 },
+ ]}
+ />
+
全部订单({detail.orders?.length ?? 0})
diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json
index c241d9e..f188aea 100644
--- a/apps/h5-partner/package.json
+++ b/apps/h5-partner/package.json
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
+ "@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
diff --git a/apps/h5-partner/src/lib/analytics.ts b/apps/h5-partner/src/lib/analytics.ts
new file mode 100644
index 0000000..582cc8f
--- /dev/null
+++ b/apps/h5-partner/src/lib/analytics.ts
@@ -0,0 +1,16 @@
+import { createPartnerTracker } from '@dukang/client-logging';
+import { apiBase } from './api';
+
+const tracker = createPartnerTracker({
+ apiBase,
+ clientApp: 'PARTNER_H5',
+ getToken: () => localStorage.getItem('partnerAccessToken'),
+});
+
+export function trackPartner(eventName: string, params?: Record) {
+ tracker.track(eventName, params);
+}
+
+export function trackPartnerPageView(eventName: string, params?: Record) {
+ tracker.trackPageView(eventName, params);
+}
diff --git a/apps/h5-partner/src/lib/usePageView.ts b/apps/h5-partner/src/lib/usePageView.ts
new file mode 100644
index 0000000..4e702ca
--- /dev/null
+++ b/apps/h5-partner/src/lib/usePageView.ts
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+import { trackPartnerPageView } from './analytics';
+
+export function usePartnerPageView(eventName: string, params?: Record) {
+ const fired = useRef(false);
+ useEffect(() => {
+ if (fired.current) return;
+ fired.current = true;
+ trackPartnerPageView(eventName, params);
+ }, [eventName, params]);
+}
diff --git a/apps/h5-partner/src/main.tsx b/apps/h5-partner/src/main.tsx
index af09a1c..6065d71 100644
--- a/apps/h5-partner/src/main.tsx
+++ b/apps/h5-partner/src/main.tsx
@@ -5,11 +5,16 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import { PartnerToastProvider } from './contexts/PartnerToastContext';
-import { installClientErrorReporting } from './lib/client-error';
+import { installClientErrorReporting } from '@dukang/client-logging';
+import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
-installClientErrorReporting();
+installClientErrorReporting({
+ apiBase,
+ clientApp: 'PARTNER_H5',
+ getToken: () => localStorage.getItem('partnerAccessToken'),
+});
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/apps/h5-partner/src/pages/BillsPage.tsx b/apps/h5-partner/src/pages/BillsPage.tsx
index f260e33..456d8f0 100644
--- a/apps/h5-partner/src/pages/BillsPage.tsx
+++ b/apps/h5-partner/src/pages/BillsPage.tsx
@@ -5,6 +5,7 @@ import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
+import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -25,6 +26,7 @@ function canConfirm(status: string) {
}
export default function BillsPage() {
+ usePartnerPageView('partner_bills_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState([]);
diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx
index 6ca4998..587dc12 100644
--- a/apps/h5-partner/src/pages/HomePage.tsx
+++ b/apps/h5-partner/src/pages/HomePage.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
+import { usePartnerPageView } from '../lib/usePageView';
import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import {
@@ -137,6 +138,7 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
}
export default function HomePage() {
+ usePartnerPageView('partner_home_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
diff --git a/apps/h5-partner/src/pages/LeaderboardPage.tsx b/apps/h5-partner/src/pages/LeaderboardPage.tsx
index 9feaa68..0136c75 100644
--- a/apps/h5-partner/src/pages/LeaderboardPage.tsx
+++ b/apps/h5-partner/src/pages/LeaderboardPage.tsx
@@ -6,6 +6,7 @@ import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isPrimaryAccount } from '../lib/partnerAccess';
+import { usePartnerPageView } from '../lib/usePageView';
const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [
{ key: 'month', label: '本月' },
@@ -26,6 +27,7 @@ function periodSubLabel(period: PartnerLeaderboardPeriod): string {
}
export default function LeaderboardPage() {
+ usePartnerPageView('partner_leaderboard_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const showStaffFab = isPrimaryAccount(account);
diff --git a/apps/h5-partner/src/pages/OrderDetailPage.tsx b/apps/h5-partner/src/pages/OrderDetailPage.tsx
index ce594b9..ff9ec44 100644
--- a/apps/h5-partner/src/pages/OrderDetailPage.tsx
+++ b/apps/h5-partner/src/pages/OrderDetailPage.tsx
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
+import { usePartnerPageView } from '../lib/usePageView';
const TIMELINE = [
{ key: 'confirm', label: '待确认' },
@@ -36,6 +37,7 @@ function canShip(status: string) {
export default function OrderDetailPage() {
const { id } = useParams();
+ usePartnerPageView('partner_order_detail_view', id ? { orderId: id } : undefined);
const navigate = useNavigate();
const [order, setOrder] = useState | null>(null);
const [shipping, setShipping] = useState(false);
diff --git a/apps/h5-partner/src/pages/OrderListPage.tsx b/apps/h5-partner/src/pages/OrderListPage.tsx
index 2712a3f..c0f4f62 100644
--- a/apps/h5-partner/src/pages/OrderListPage.tsx
+++ b/apps/h5-partner/src/pages/OrderListPage.tsx
@@ -5,6 +5,7 @@ import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
+import { usePartnerPageView } from '../lib/usePageView';
type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
@@ -44,6 +45,7 @@ type OrdersResponse = {
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
+ usePartnerPageView('partner_order_list_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const warehouseOk = hasWarehouseAccess(account);
diff --git a/apps/h5-partner/src/pages/ProxyOrderPage.tsx b/apps/h5-partner/src/pages/ProxyOrderPage.tsx
index 4cca0f8..a1fe290 100644
--- a/apps/h5-partner/src/pages/ProxyOrderPage.tsx
+++ b/apps/h5-partner/src/pages/ProxyOrderPage.tsx
@@ -24,6 +24,7 @@ import type {
ProxyOrderPayResponse,
ProxyPayMethod,
} from '@dukang/shared-types';
+import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -46,6 +47,7 @@ function initialDraft(): ProxyOrderDraft {
}
export default function ProxyOrderPage() {
+ usePartnerPageView('partner_proxy_order_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const draft0 = useMemo(() => initialDraft(), []);
diff --git a/apps/h5-partner/src/pages/StaffListPage.tsx b/apps/h5-partner/src/pages/StaffListPage.tsx
index fbb706f..0af0e64 100644
--- a/apps/h5-partner/src/pages/StaffListPage.tsx
+++ b/apps/h5-partner/src/pages/StaffListPage.tsx
@@ -9,8 +9,10 @@ import {
type PartnerStaffItem,
} from '@dukang/shared-types';
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
+import { usePartnerPageView } from '../lib/usePageView';
export default function StaffListPage() {
+ usePartnerPageView('partner_staff_list_view');
const navigate = useNavigate();
const [staff, setStaff] = useState([]);
const [q, setQ] = useState('');
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx
index 1e57293..d9a2b26 100644
--- a/apps/h5-partner/src/pages/StoreCreatePage.tsx
+++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx
@@ -20,6 +20,7 @@ import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
+import { usePartnerPageView } from '../lib/usePageView';
import {
@@ -78,6 +79,8 @@ function isPhoneValidationMessage(message: string) {
export default function StoreCreatePage() {
+ usePartnerPageView('partner_store_create_view');
+
const navigate = useNavigate();
const { account } = usePartnerSession();
diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx
index 94ef10c..516f0f0 100644
--- a/apps/h5-partner/src/pages/StoreDetailPage.tsx
+++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx
@@ -16,6 +16,7 @@ import {
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
+import { usePartnerPageView } from '../lib/usePageView';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
@@ -33,6 +34,7 @@ function uniqueEnvUrls(urls: string[]): string[] {
export default function StoreDetailPage() {
const { id } = useParams();
+ usePartnerPageView('partner_store_detail_view', id ? { storeId: id } : undefined);
const navigate = useNavigate();
const { account } = usePartnerSession();
const canMutate = canManagePartnerStore(account);
diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx
index 0a4d4ca..1fac980 100644
--- a/apps/h5-partner/src/pages/StoreListPage.tsx
+++ b/apps/h5-partner/src/pages/StoreListPage.tsx
@@ -13,6 +13,7 @@ import {
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
+import { usePartnerPageView } from '../lib/usePageView';
type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
@@ -26,6 +27,7 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
];
export default function StoreListPage() {
+ usePartnerPageView('partner_store_list_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
diff --git a/apps/h5-partner/src/pages/WeeklyReportPage.tsx b/apps/h5-partner/src/pages/WeeklyReportPage.tsx
index fcec099..7350b8b 100644
--- a/apps/h5-partner/src/pages/WeeklyReportPage.tsx
+++ b/apps/h5-partner/src/pages/WeeklyReportPage.tsx
@@ -4,6 +4,7 @@ import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
+import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
@@ -15,6 +16,7 @@ function fmtGrowth(n: number) {
}
export default function WeeklyReportPage() {
+ usePartnerPageView('partner_weekly_report_view');
const navigate = useNavigate();
const [selectedStart, setSelectedStart] = useState();
const [data, setData] = useState(null);
diff --git a/apps/h5-shop/package.json b/apps/h5-shop/package.json
index 62b7619..31607d9 100644
--- a/apps/h5-shop/package.json
+++ b/apps/h5-shop/package.json
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
+ "@dukang/client-logging": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
diff --git a/apps/h5-shop/src/lib/analytics.ts b/apps/h5-shop/src/lib/analytics.ts
new file mode 100644
index 0000000..c69e30c
--- /dev/null
+++ b/apps/h5-shop/src/lib/analytics.ts
@@ -0,0 +1,26 @@
+import { createStoreTracker } from '@dukang/client-logging';
+import { apiBase } from './api';
+
+const tracker = createStoreTracker({
+ apiBase,
+ clientApp: 'SHOP_H5',
+ getToken: () => localStorage.getItem('shopAccessToken'),
+ getStoreId: () => {
+ try {
+ const raw = localStorage.getItem('shopSession');
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as { storeId?: string };
+ return parsed.storeId ?? null;
+ } catch {
+ return null;
+ }
+ },
+});
+
+export function trackStore(eventName: string, params?: Record) {
+ tracker.track(eventName, params);
+}
+
+export function trackStorePageView(eventName: string, params?: Record) {
+ tracker.trackPageView(eventName, params);
+}
diff --git a/apps/h5-shop/src/lib/usePageView.ts b/apps/h5-shop/src/lib/usePageView.ts
new file mode 100644
index 0000000..668d570
--- /dev/null
+++ b/apps/h5-shop/src/lib/usePageView.ts
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+import { trackStorePageView } from './analytics';
+
+export function useStorePageView(eventName: string, params?: Record) {
+ const fired = useRef(false);
+ useEffect(() => {
+ if (fired.current) return;
+ fired.current = true;
+ trackStorePageView(eventName, params);
+ }, [eventName, params]);
+}
diff --git a/apps/h5-shop/src/main.tsx b/apps/h5-shop/src/main.tsx
index 39b4bd4..d0c0474 100644
--- a/apps/h5-shop/src/main.tsx
+++ b/apps/h5-shop/src/main.tsx
@@ -3,12 +3,17 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { getRouterBasename } from '@dukang/weixin-sdk';
import { StoreSessionProvider } from './contexts/StoreSessionContext';
-import { installClientErrorReporting } from './lib/client-error';
+import { installClientErrorReporting } from '@dukang/client-logging';
import App from './App';
+import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
-installClientErrorReporting();
+installClientErrorReporting({
+ apiBase,
+ clientApp: 'SHOP_H5',
+ getToken: () => localStorage.getItem('shopAccessToken'),
+});
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx
index 3f7a278..966ffb6 100644
--- a/apps/h5-shop/src/pages/HomePage.tsx
+++ b/apps/h5-shop/src/pages/HomePage.tsx
@@ -14,6 +14,8 @@ import {
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
+import { useStorePageView } from '../lib/usePageView';
+import { trackStore } from '../lib/analytics';
const PENDING_SCAN_KEY = 'shop_pending_scan';
@@ -33,6 +35,7 @@ function formatScanError(e: unknown): string {
}
export default function HomePage() {
+ useStorePageView('store_home_view');
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
@@ -74,6 +77,7 @@ export default function HomePage() {
}, [loadDashboard]);
async function runScan(opts?: { postAuthWarmup?: boolean }) {
+ trackStore('store_redeem_scan_start');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx
index 9ffd150..cc4d41a 100644
--- a/apps/h5-shop/src/pages/MinePage.tsx
+++ b/apps/h5-shop/src/pages/MinePage.tsx
@@ -13,8 +13,10 @@ import {
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
+import { useStorePageView } from '../lib/usePageView';
export default function MinePage() {
+ useStorePageView('store_mine_view');
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
diff --git a/apps/h5-shop/src/pages/PackagesPage.tsx b/apps/h5-shop/src/pages/PackagesPage.tsx
index 41af591..80d4183 100644
--- a/apps/h5-shop/src/pages/PackagesPage.tsx
+++ b/apps/h5-shop/src/pages/PackagesPage.tsx
@@ -4,6 +4,7 @@ import type { StorePackagesResponse } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api';
+import { useStorePageView } from '../lib/usePageView';
import {
emptyPackage,
normalizePackageFormItems,
@@ -12,6 +13,7 @@ import {
} from '../lib/storePackages';
export default function PackagesPage() {
+ useStorePageView('store_packages_view');
const navigate = useNavigate();
const [items, setItems] = useState([emptyPackage()]);
const [pending, setPending] = useState(null);
diff --git a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx
index c054ebf..db418ba 100644
--- a/apps/h5-shop/src/pages/PhoneRedeemPage.tsx
+++ b/apps/h5-shop/src/pages/PhoneRedeemPage.tsx
@@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api';
+import { useStorePageView } from '../lib/usePageView';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function PhoneRedeemPage() {
+ useStorePageView('store_phone_redeem_view');
const navigate = useNavigate();
const [phone, setPhone] = useState('');
const [amount, setAmount] = useState('');
diff --git a/apps/h5-shop/src/pages/RecordsPage.tsx b/apps/h5-shop/src/pages/RecordsPage.tsx
index 6c6733a..9fda27c 100644
--- a/apps/h5-shop/src/pages/RecordsPage.tsx
+++ b/apps/h5-shop/src/pages/RecordsPage.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
import { request } from '../lib/api';
+import { useStorePageView } from '../lib/usePageView';
type RangeKey = 'today' | '7d' | '30d';
type StatusFilter = 'all' | 'pending' | 'paid';
@@ -30,6 +31,7 @@ function channelLabel(channel: unknown): string {
}
export default function RecordsPage() {
+ useStorePageView('store_records_view');
const [records, setRecords] = useState>>([]);
const [range, setRange] = useState('today');
const [statusFilter, setStatusFilter] = useState('all');
diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx
index 2b6a09f..8cf8bcd 100644
--- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx
+++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
import { request } from '../lib/api';
import { reportRedeemFailure } from '../lib/redeem-failure';
+import { useStorePageView } from '../lib/usePageView';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -17,6 +18,7 @@ type Preview = {
};
export default function RedeemConfirmPage() {
+ useStorePageView('store_redeem_confirm_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [token, setToken] = useState('');
diff --git a/apps/h5-shop/src/pages/StaffPage.tsx b/apps/h5-shop/src/pages/StaffPage.tsx
index 6105aab..3887a0b 100644
--- a/apps/h5-shop/src/pages/StaffPage.tsx
+++ b/apps/h5-shop/src/pages/StaffPage.tsx
@@ -4,6 +4,7 @@ import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-typ
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api';
+import { useStorePageView } from '../lib/usePageView';
type StaffItem = {
id: string;
@@ -23,6 +24,7 @@ function maskPhone(phone: string) {
}
export default function StaffPage() {
+ useStorePageView('store_staff_view');
const navigate = useNavigate();
const { store } = useStoreSession();
const profile = store ?? getStoreProfile();
diff --git a/apps/h5-shop/src/pages/WithdrawPage.tsx b/apps/h5-shop/src/pages/WithdrawPage.tsx
index 1f14708..29b7a7a 100644
--- a/apps/h5-shop/src/pages/WithdrawPage.tsx
+++ b/apps/h5-shop/src/pages/WithdrawPage.tsx
@@ -8,6 +8,7 @@ import {
} from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
+import { useStorePageView } from '../lib/usePageView';
type StatusFilter = 'all' | StoreWithdrawStatus;
@@ -16,6 +17,7 @@ function formatMoney(n: number) {
}
export default function WithdrawPage() {
+ useStorePageView('store_withdraw_view');
const navigate = useNavigate();
const [summary, setSummary] = useState(null);
const [items, setItems] = useState([]);
diff --git a/apps/h5-user/package.json b/apps/h5-user/package.json
index 5865180..7d9941e 100644
--- a/apps/h5-user/package.json
+++ b/apps/h5-user/package.json
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
+ "@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
diff --git a/apps/h5-user/src/lib/analytics.ts b/apps/h5-user/src/lib/analytics.ts
index 81adcaf..06915a3 100644
--- a/apps/h5-user/src/lib/analytics.ts
+++ b/apps/h5-user/src/lib/analytics.ts
@@ -1,16 +1,21 @@
+import { createUserTracker, getSessionId } from '@dukang/client-logging';
import { apiBase } from './api';
-export function track(eventName: string, params?: Record) {
- const token = localStorage.getItem('accessToken');
- if (!token) return;
+const tracker = createUserTracker({
+ apiBase,
+ clientApp: 'USER_H5',
+});
- void fetch(`${apiBase}/analytics/events`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
- 'X-Client-App': 'USER_H5',
- },
- body: JSON.stringify({ events: [{ eventName, params }] }),
- }).catch(() => {});
+export { getSessionId };
+
+export function track(eventName: string, params?: Record) {
+ tracker.track(eventName, params);
+}
+
+export function trackPageView(eventName: string, params?: Record) {
+ tracker.trackPageView(eventName, params);
+}
+
+export function initUserAnalytics() {
+ tracker.trackSessionStart();
}
diff --git a/apps/h5-user/src/lib/usePageView.ts b/apps/h5-user/src/lib/usePageView.ts
new file mode 100644
index 0000000..d3e9fd7
--- /dev/null
+++ b/apps/h5-user/src/lib/usePageView.ts
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+import { trackPageView } from './analytics';
+
+export function usePageView(eventName: string, params?: Record) {
+ const fired = useRef(false);
+ useEffect(() => {
+ if (fired.current) return;
+ fired.current = true;
+ trackPageView(eventName, params);
+ }, [eventName, params]);
+}
diff --git a/apps/h5-user/src/main.tsx b/apps/h5-user/src/main.tsx
index 94007ec..53d311f 100644
--- a/apps/h5-user/src/main.tsx
+++ b/apps/h5-user/src/main.tsx
@@ -2,10 +2,16 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { getRouterBasename } from '@dukang/weixin-sdk';
+import { installClientErrorReporting } from '@dukang/client-logging';
import App from './App';
+import { initUserAnalytics } from './lib/analytics';
+import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
+installClientErrorReporting({ apiBase, clientApp: 'USER_H5' });
+initUserAnalytics();
+
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/apps/h5-user/src/pages/AddressEditPage.tsx b/apps/h5-user/src/pages/AddressEditPage.tsx
index 8967412..62e3ce3 100644
--- a/apps/h5-user/src/pages/AddressEditPage.tsx
+++ b/apps/h5-user/src/pages/AddressEditPage.tsx
@@ -6,6 +6,7 @@ import { request } from '../lib/api';
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
import { DEFAULT_REGION, formatRegion } from '../lib/region-data';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
+import { usePageView } from '../lib/usePageView';
type AddressForm = {
receiverName: string;
@@ -21,6 +22,7 @@ export default function AddressEditPage() {
const { id } = useParams();
const [params] = useSearchParams();
const isEdit = Boolean(id);
+ usePageView('address_edit', { mode: isEdit ? 'edit' : 'create' });
const navigate = useNavigate();
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
diff --git a/apps/h5-user/src/pages/AddressListPage.tsx b/apps/h5-user/src/pages/AddressListPage.tsx
index 7f17d39..548b7f3 100644
--- a/apps/h5-user/src/pages/AddressListPage.tsx
+++ b/apps/h5-user/src/pages/AddressListPage.tsx
@@ -4,6 +4,7 @@ import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { useUserSession } from '../contexts/UserSessionContext';
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
+import { usePageView } from '../lib/usePageView';
type Address = {
id: string;
@@ -25,6 +26,7 @@ function formatAddress(a: Address) {
}
export default function AddressListPage() {
+ usePageView('address_list_view');
const { profile } = useUserSession();
const [list, setList] = useState([]);
const [pendingAddress, setPendingAddress] = useState(null);
diff --git a/apps/h5-user/src/pages/AfterSaleListPage.tsx b/apps/h5-user/src/pages/AfterSaleListPage.tsx
index 12f1f5e..dc04bf3 100644
--- a/apps/h5-user/src/pages/AfterSaleListPage.tsx
+++ b/apps/h5-user/src/pages/AfterSaleListPage.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
+import { usePageView } from '../lib/usePageView';
type TicketRow = {
id: string;
@@ -23,6 +24,7 @@ const STATUS_LABEL: Record = {
};
export default function AfterSaleListPage() {
+ usePageView('after_sale_list_view');
const navigate = useNavigate();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
diff --git a/apps/h5-user/src/pages/AfterSalePage.tsx b/apps/h5-user/src/pages/AfterSalePage.tsx
index a437150..a84d22b 100644
--- a/apps/h5-user/src/pages/AfterSalePage.tsx
+++ b/apps/h5-user/src/pages/AfterSalePage.tsx
@@ -8,6 +8,8 @@ import {
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { uploadFileToOss } from '../lib/upload';
+import { usePageView } from '../lib/usePageView';
+import { track } from '../lib/analytics';
type OrderRow = {
id: string;
@@ -21,6 +23,7 @@ type OrderRow = {
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
export default function AfterSalePage() {
+ usePageView('after_sale_apply');
const navigate = useNavigate();
const [params] = useSearchParams();
const presetOrderId = params.get('orderId') || '';
diff --git a/apps/h5-user/src/pages/BenefitDetailPage.tsx b/apps/h5-user/src/pages/BenefitDetailPage.tsx
index b362aaf..e5e735b 100644
--- a/apps/h5-user/src/pages/BenefitDetailPage.tsx
+++ b/apps/h5-user/src/pages/BenefitDetailPage.tsx
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
+import { usePageView } from '../lib/usePageView';
export default function BenefitDetailPage() {
const { id } = useParams();
+ usePageView('benefit_detail_view', { couponId: id });
const navigate = useNavigate();
const [data, setData] = useState<{ coupon: Record; ledgers: Array> } | null>(null);
useEffect(() => {
diff --git a/apps/h5-user/src/pages/BenefitPage.tsx b/apps/h5-user/src/pages/BenefitPage.tsx
index f56fff9..7f74699 100644
--- a/apps/h5-user/src/pages/BenefitPage.tsx
+++ b/apps/h5-user/src/pages/BenefitPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import BrandLogo from '@dukang/shared-ui/BrandLogo';
import { request } from '../lib/api';
+import { usePageView } from '../lib/usePageView';
type BenefitSummary = {
totalBalance: number;
@@ -44,6 +45,7 @@ function buildRedeemUrl(coupon?: CouponItem) {
}
export default function BenefitPage() {
+ usePageView('benefit_page_view');
const navigate = useNavigate();
const listRef = useRef(null);
const [summary, setSummary] = useState(null);
diff --git a/apps/h5-user/src/pages/OrderDetailPage.tsx b/apps/h5-user/src/pages/OrderDetailPage.tsx
index ad3366b..8ef7b6b 100644
--- a/apps/h5-user/src/pages/OrderDetailPage.tsx
+++ b/apps/h5-user/src/pages/OrderDetailPage.tsx
@@ -7,6 +7,7 @@ import { buildOrderAddressSelectUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { handleShareButtonClick } from '../lib/wechat-share';
import ContactCustomerSheet from '../components/ContactCustomerSheet';
+import { usePageView } from '../lib/usePageView';
type OrderItem = {
productName: string;
@@ -131,6 +132,7 @@ function fullReceiverAddress(order: Order) {
export default function OrderDetailPage() {
const { id } = useParams();
+ usePageView('order_detail_view', { orderId: id });
const navigate = useNavigate();
const [order, setOrder] = useState(null);
const [showCs, setShowCs] = useState(false);
diff --git a/apps/h5-user/src/pages/OrderListPage.tsx b/apps/h5-user/src/pages/OrderListPage.tsx
index db6a888..f2a4baf 100644
--- a/apps/h5-user/src/pages/OrderListPage.tsx
+++ b/apps/h5-user/src/pages/OrderListPage.tsx
@@ -4,6 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import OrderStatusTabs from '@dukang/shared-ui/OrderStatusTabs';
import { request } from '../lib/api';
+import { usePageView } from '../lib/usePageView';
const TABS = [
{ key: 'all', label: '全部' },
@@ -36,6 +37,7 @@ function orderStatusLabel(order: Record) {
export default function OrderListPage() {
const [params, setParams] = useSearchParams();
const tab = params.get('tab') || 'all';
+ usePageView('order_list_view', { tab });
const [data, setData] = useState<{ list: Array> }>({ list: [] });
const navigate = useNavigate();
diff --git a/apps/h5-user/src/pages/PayPage.tsx b/apps/h5-user/src/pages/PayPage.tsx
index e318cc0..9c68908 100644
--- a/apps/h5-user/src/pages/PayPage.tsx
+++ b/apps/h5-user/src/pages/PayPage.tsx
@@ -15,6 +15,8 @@ import {
} from '../lib/pay-wechat';
import { applyWechatLoginResult, handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
+import { usePageView } from '../lib/usePageView';
+import { track } from '../lib/analytics';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -32,6 +34,7 @@ async function waitOrderPaid(orderId: string, maxAttempts = 15) {
export default function PayPage() {
const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
+ usePageView('pay_page_view', { orderId });
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
@@ -161,6 +164,11 @@ export default function PayPage() {
return;
}
setMsg(e instanceof Error ? e.message : '支付失败');
+ track('pay_fail', {
+ orderId,
+ failReason: e instanceof Error ? e.message : '支付失败',
+ pagePath: '/pay',
+ });
} finally {
setLoading(false);
}
diff --git a/apps/h5-user/src/pages/ProductDetailPage.tsx b/apps/h5-user/src/pages/ProductDetailPage.tsx
index 0ceab31..e29c6a9 100644
--- a/apps/h5-user/src/pages/ProductDetailPage.tsx
+++ b/apps/h5-user/src/pages/ProductDetailPage.tsx
@@ -155,7 +155,10 @@ export default function ProductDetailPage() {
diff --git a/apps/h5-user/src/pages/RedeemCodePage.tsx b/apps/h5-user/src/pages/RedeemCodePage.tsx
index 4949080..b5e4229 100644
--- a/apps/h5-user/src/pages/RedeemCodePage.tsx
+++ b/apps/h5-user/src/pages/RedeemCodePage.tsx
@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
+import { usePageView } from '../lib/usePageView';
export default function RedeemCodePage() {
+ usePageView('redeem_code_view');
const navigate = useNavigate();
const token = sessionStorage.getItem('redeemToken') || '';
const amount = sessionStorage.getItem('redeemAmount') || '0';
diff --git a/apps/h5-user/src/pages/RedeemPage.tsx b/apps/h5-user/src/pages/RedeemPage.tsx
index 246fdce..1f1d89d 100644
--- a/apps/h5-user/src/pages/RedeemPage.tsx
+++ b/apps/h5-user/src/pages/RedeemPage.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import { request } from '../lib/api';
+import { usePageView } from '../lib/usePageView';
const POLL_INTERVAL_MS = 2500;
@@ -37,6 +38,7 @@ function formatTimer(seconds: number) {
}
export default function RedeemPage() {
+ usePageView('benefit_redeem_start');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const couponId = searchParams.get('couponId') ?? undefined;
diff --git a/apps/h5-user/src/pages/RedeemSuccessPage.tsx b/apps/h5-user/src/pages/RedeemSuccessPage.tsx
index 73459c3..bb4cd1f 100644
--- a/apps/h5-user/src/pages/RedeemSuccessPage.tsx
+++ b/apps/h5-user/src/pages/RedeemSuccessPage.tsx
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
+import { usePageView } from '../lib/usePageView';
type RedeemRecord = {
id: string;
@@ -46,6 +47,7 @@ function StarRating({
}
export default function RedeemSuccessPage() {
+ usePageView('redeem_success_view');
const navigate = useNavigate();
const location = useLocation();
const [serviceScore, setServiceScore] = useState(5);
diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json
index 7f80223..5b369d6 100644
--- a/apps/mini-user/package.json
+++ b/apps/mini-user/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@babel/runtime": "^7.24.4",
+ "@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
diff --git a/apps/mini-user/src/lib/analytics.ts b/apps/mini-user/src/lib/analytics.ts
new file mode 100644
index 0000000..d21c820
--- /dev/null
+++ b/apps/mini-user/src/lib/analytics.ts
@@ -0,0 +1,22 @@
+import { createUserTracker, getSessionId } from '@dukang/client-logging';
+import { API_BASE, CLIENT_APP, getToken } from './api';
+
+const tracker = createUserTracker({
+ apiBase: API_BASE,
+ clientApp: CLIENT_APP,
+ getToken,
+});
+
+export { getSessionId };
+
+export function track(eventName: string, params?: Record) {
+ tracker.track(eventName, params);
+}
+
+export function trackPageView(eventName: string, params?: Record) {
+ tracker.trackPageView(eventName, params);
+}
+
+export function initUserAnalytics() {
+ tracker.trackSessionStart();
+}
diff --git a/apps/mini-user/src/lib/usePageView.ts b/apps/mini-user/src/lib/usePageView.ts
new file mode 100644
index 0000000..d3e9fd7
--- /dev/null
+++ b/apps/mini-user/src/lib/usePageView.ts
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+import { trackPageView } from './analytics';
+
+export function usePageView(eventName: string, params?: Record) {
+ const fired = useRef(false);
+ useEffect(() => {
+ if (fired.current) return;
+ fired.current = true;
+ trackPageView(eventName, params);
+ }, [eventName, params]);
+}
diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx
index e969609..0ddfdee 100644
--- a/apps/mini-user/src/pages/home/index.tsx
+++ b/apps/mini-user/src/pages/home/index.tsx
@@ -33,6 +33,7 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
+import { trackPageView } from '../../lib/analytics';
type Product = {
id: string;
name: string;
@@ -79,6 +80,10 @@ export default function HomePage() {
const scrollLockTimerRef = useRef | null>(null);
const lastScrollSyncAtRef = useRef(0);
+ useEffect(() => {
+ trackPageView('home_view', { pagePath: '/pages/home/index', cityCode });
+ }, [cityCode]);
+
const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
.then((cfg) => {
diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx
index a3f3530..51587c8 100644
--- a/apps/mini-user/src/pages/order-detail/index.tsx
+++ b/apps/mini-user/src/pages/order-detail/index.tsx
@@ -15,6 +15,7 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
+import { usePageView } from '../../lib/usePageView';
const isWeapp = process.env.TARO_ENV === 'weapp';
@@ -74,6 +75,7 @@ function fullReceiverAddress(order: OrderDetail) {
export default function OrderDetailPage() {
const router = useRouter();
const orderId = router.params.id ?? '';
+ usePageView('order_detail_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState(null);
const [confirming, setConfirming] = useState(false);
diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx
index 6e44413..7c3f6cf 100644
--- a/apps/mini-user/src/pages/orders/index.tsx
+++ b/apps/mini-user/src/pages/orders/index.tsx
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
+import { usePageView } from '../../lib/usePageView';
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
@@ -54,6 +55,7 @@ type OrderRow = {
export default function OrdersPage() {
const router = useRouter();
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
+ usePageView('order_list_view', { tab });
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx
index a480ee7..9697354 100644
--- a/apps/mini-user/src/pages/product-detail/index.tsx
+++ b/apps/mini-user/src/pages/product-detail/index.tsx
@@ -33,6 +33,7 @@ import {
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconHome from '../../assets/tabbar/home.png';
+import { usePageView } from '../../lib/usePageView';
type Product = ProductImageSource & {
id: string;
@@ -50,6 +51,10 @@ type Product = ProductImageSource & {
export default function ProductDetailPage() {
const router = useRouter();
const productId = router.params.id ?? '';
+ usePageView(
+ 'product_detail_view',
+ productId ? { refType: 'PRODUCT', refId: productId, productId } : undefined,
+ );
const [product, setProduct] = useState(null);
const [headerSolid, setHeaderSolid] = useState(false);
diff --git a/packages/client-logging/package.json b/packages/client-logging/package.json
new file mode 100644
index 0000000..e1ee147
--- /dev/null
+++ b/packages/client-logging/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@dukang/client-logging",
+ "version": "0.1.0",
+ "private": true,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./src/index.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "lint": "eslint src"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/packages/client-logging/src/analytics.ts b/packages/client-logging/src/analytics.ts
new file mode 100644
index 0000000..f0b67e8
--- /dev/null
+++ b/packages/client-logging/src/analytics.ts
@@ -0,0 +1,121 @@
+import { getSessionId } from './session';
+
+export type TrackParams = Record;
+
+export type UserTrackerOptions = {
+ apiBase: string;
+ clientApp: string;
+ getToken?: () => string | null;
+};
+
+export function createUserTracker(opts: UserTrackerOptions) {
+ const getToken = opts.getToken ?? (() => localStorage.getItem('accessToken'));
+
+ function track(eventName: string, params?: TrackParams) {
+ const sessionId = getSessionId();
+ const token = getToken();
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-Client-App': opts.clientApp,
+ };
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ void fetch(`${opts.apiBase}/analytics/events`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ sessionId,
+ clientApp: opts.clientApp,
+ events: [{ eventName, params: { sessionId, ...params } }],
+ }),
+ }).catch(() => {});
+ }
+
+ function trackPageView(eventName: string, params?: TrackParams) {
+ track(eventName, {
+ pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
+ ...params,
+ });
+ }
+
+ function trackSessionStart() {
+ track('session_start', {
+ pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
+ });
+ }
+
+ return { track, trackPageView, trackSessionStart };
+}
+
+export type StoreTrackerOptions = {
+ apiBase: string;
+ clientApp: string;
+ getToken: () => string | null;
+ getStoreId: () => string | null;
+};
+
+export function createStoreTracker(opts: StoreTrackerOptions) {
+ function track(eventName: string, params?: TrackParams) {
+ const token = opts.getToken();
+ const storeId = opts.getStoreId();
+ if (!token || !storeId) return;
+
+ void fetch(`${opts.apiBase}/analytics/store-events`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ 'X-Client-App': opts.clientApp,
+ },
+ body: JSON.stringify({
+ sessionId: getSessionId(),
+ storeId,
+ events: [{ eventName, params: { sessionId: getSessionId(), storeId, ...params } }],
+ }),
+ }).catch(() => {});
+ }
+
+ function trackPageView(eventName: string, params?: TrackParams) {
+ track(eventName, {
+ pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
+ ...params,
+ });
+ }
+
+ return { track, trackPageView };
+}
+
+export type PartnerTrackerOptions = {
+ apiBase: string;
+ clientApp: string;
+ getToken: () => string | null;
+};
+
+export function createPartnerTracker(opts: PartnerTrackerOptions) {
+ function track(eventName: string, params?: TrackParams) {
+ const token = opts.getToken();
+ if (!token) return;
+
+ void fetch(`${opts.apiBase}/analytics/partner-events`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ 'X-Client-App': opts.clientApp,
+ },
+ body: JSON.stringify({
+ sessionId: getSessionId(),
+ events: [{ eventName, params: { sessionId: getSessionId(), ...params } }],
+ }),
+ }).catch(() => {});
+ }
+
+ function trackPageView(eventName: string, params?: TrackParams) {
+ track(eventName, {
+ pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
+ ...params,
+ });
+ }
+
+ return { track, trackPageView };
+}
diff --git a/packages/client-logging/src/client-error.ts b/packages/client-logging/src/client-error.ts
new file mode 100644
index 0000000..8c8146a
--- /dev/null
+++ b/packages/client-logging/src/client-error.ts
@@ -0,0 +1,93 @@
+export type ClientErrorLevel = 'fatal' | 'error' | 'warn' | 'info';
+export type ClientErrorCategory =
+ | 'js_error'
+ | 'unhandled_rejection'
+ | 'api_error'
+ | 'network'
+ | 'render'
+ | 'bridge'
+ | 'other';
+
+export type ClientErrorReporterOptions = {
+ apiBase: string;
+ clientApp: string;
+ getToken?: () => string | null;
+};
+
+export function installClientErrorReporting(opts: ClientErrorReporterOptions) {
+ if (typeof window === 'undefined') return;
+
+ const report = (payload: {
+ level: ClientErrorLevel;
+ category: ClientErrorCategory;
+ message: string;
+ stack?: string;
+ extra?: Record;
+ }) => {
+ const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-Client-App': opts.clientApp,
+ };
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ void fetch(`${opts.apiBase}/common/client-errors`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ level: payload.level,
+ category: payload.category,
+ message: payload.message,
+ stack: payload.stack,
+ pagePath: window.location.pathname,
+ clientApp: opts.clientApp,
+ extra: payload.extra,
+ }),
+ }).catch(() => {});
+ };
+
+ window.addEventListener('error', (ev) => {
+ report({
+ level: 'error',
+ category: 'js_error',
+ message: ev.message || 'Unknown error',
+ stack: ev.error instanceof Error ? ev.error.stack : undefined,
+ });
+ });
+
+ window.addEventListener('unhandledrejection', (ev) => {
+ const reason = ev.reason;
+ report({
+ level: 'error',
+ category: 'unhandled_rejection',
+ message: reason instanceof Error ? reason.message : String(reason),
+ stack: reason instanceof Error ? reason.stack : undefined,
+ });
+ });
+}
+
+export function reportApiError(
+ opts: ClientErrorReporterOptions,
+ input: { message: string; status?: number; url?: string; category?: ClientErrorCategory },
+) {
+ if (typeof window === 'undefined') return;
+ const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-Client-App': opts.clientApp,
+ };
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ void fetch(`${opts.apiBase}/common/client-errors`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ level: 'warn',
+ category: input.category ?? 'api_error',
+ message: input.message.slice(0, 1000),
+ pagePath: window.location.pathname,
+ clientApp: opts.clientApp,
+ extra: { status: input.status, url: input.url },
+ }),
+ }).catch(() => {});
+}
diff --git a/packages/client-logging/src/index.ts b/packages/client-logging/src/index.ts
new file mode 100644
index 0000000..a2d7ca3
--- /dev/null
+++ b/packages/client-logging/src/index.ts
@@ -0,0 +1,17 @@
+export { getSessionId, touchSession } from './session';
+export {
+ createUserTracker,
+ createStoreTracker,
+ createPartnerTracker,
+ type TrackParams,
+ type UserTrackerOptions,
+ type StoreTrackerOptions,
+ type PartnerTrackerOptions,
+} from './analytics';
+export {
+ installClientErrorReporting,
+ reportApiError,
+ type ClientErrorLevel,
+ type ClientErrorCategory,
+ type ClientErrorReporterOptions,
+} from './client-error';
diff --git a/packages/client-logging/src/session.ts b/packages/client-logging/src/session.ts
new file mode 100644
index 0000000..8f892a6
--- /dev/null
+++ b/packages/client-logging/src/session.ts
@@ -0,0 +1,18 @@
+const SESSION_KEY = 'dukang_session_id';
+
+export function getSessionId(): string {
+ if (typeof localStorage === 'undefined') return 'ssr';
+ let id = localStorage.getItem(SESSION_KEY);
+ if (!id) {
+ id =
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `s_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
+ localStorage.setItem(SESSION_KEY, id);
+ }
+ return id;
+}
+
+export function touchSession(): string {
+ return getSessionId();
+}
diff --git a/packages/client-logging/tsconfig.json b/packages/client-logging/tsconfig.json
new file mode 100644
index 0000000..9862cf0
--- /dev/null
+++ b/packages/client-logging/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../domain/tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "lib": ["ES2020", "DOM"],
+ "declaration": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/shared-types/src/partner-log.ts b/packages/shared-types/src/partner-log.ts
index b17fee7..dd8e7d4 100644
--- a/packages/shared-types/src/partner-log.ts
+++ b/packages/shared-types/src/partner-log.ts
@@ -1,6 +1,7 @@
export type PartnerLogCategory =
| 'login'
| 'wechat_auth'
+ | 'page_view'
| 'account_ops'
| 'store_ops'
| 'shipping'
@@ -10,6 +11,20 @@ export type PartnerLogCategory =
export const PARTNER_LOG_EVENT_CATEGORIES: Record = {
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
+ page_view: [
+ 'partner_home_view',
+ 'partner_store_list_view',
+ 'partner_store_detail_view',
+ 'partner_store_create_view',
+ 'partner_order_list_view',
+ 'partner_order_detail_view',
+ 'partner_proxy_order_view',
+ 'partner_bills_view',
+ 'partner_bill_detail_view',
+ 'partner_staff_list_view',
+ 'partner_leaderboard_view',
+ 'partner_weekly_report_view',
+ ],
account_ops: [
'partner_staff_create',
'partner_staff_sms_send',
@@ -24,7 +39,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record | null;
createdAt: string;
}
+
+export type PartnerAnalyticsExtra = {
+ sessionId?: string;
+ partnerAccountId?: string;
+ storeId?: string;
+ orderId?: string;
+ billId?: string;
+ cityCode?: string;
+ pagePath?: string;
+};
diff --git a/packages/shared-types/src/store-log.ts b/packages/shared-types/src/store-log.ts
index 7bb053a..e286b49 100644
--- a/packages/shared-types/src/store-log.ts
+++ b/packages/shared-types/src/store-log.ts
@@ -1,13 +1,32 @@
export type StoreLogCategory =
| 'login'
| 'wechat_auth'
+ | 'page_view'
| 'redeem'
| 'payout'
- | 'store_ops';
+ | 'store_ops'
+ | 'package';
export const STORE_LOG_EVENT_CATEGORIES: Record = {
- login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
+ login: [
+ 'store_sms_send',
+ 'store_sms_login',
+ 'store_sms_verify_fail',
+ 'store_login_success',
+ 'store_select',
+ ],
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
+ page_view: [
+ 'store_home_view',
+ 'store_redeem_entry_view',
+ 'store_redeem_confirm_view',
+ 'store_phone_redeem_view',
+ 'store_records_view',
+ 'store_withdraw_view',
+ 'store_packages_view',
+ 'store_mine_view',
+ 'store_staff_view',
+ ],
redeem: [
'store_redeem_preview',
'store_redeem_confirm',
@@ -19,27 +38,39 @@ export const STORE_LOG_EVENT_CATEGORIES: Record = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'wechat_auth', label: '授权' },
+ { value: 'page_view', label: '页面浏览' },
{ value: 'redeem', label: '核销' },
{ value: 'payout', label: '提现/打款' },
{ value: 'store_ops', label: '门店操作' },
+ { value: 'package', label: '套餐' },
];
export const STORE_LOG_CATEGORY_LABELS: Record = {
'': '全部',
login: '登录',
wechat_auth: '授权',
+ page_view: '页面浏览',
redeem: '核销',
payout: '提现/打款',
store_ops: '门店操作',
+ package: '套餐',
};
export function resolveStoreLogCategory(eventName: string): StoreLogCategory | null {
@@ -72,3 +103,14 @@ export interface StoreLogRowDto {
extraJson: Record | null;
createdAt: string;
}
+
+export type StoreAnalyticsExtra = {
+ sessionId?: string;
+ storeId?: string;
+ storeAccountId?: string;
+ redeemChannel?: 'scan' | 'phone' | 'pending';
+ amount?: number;
+ failReason?: string;
+ durationMs?: number;
+ pagePath?: string;
+};
diff --git a/packages/shared-types/src/user-log.ts b/packages/shared-types/src/user-log.ts
index 41daaf8..0ce903c 100644
--- a/packages/shared-types/src/user-log.ts
+++ b/packages/shared-types/src/user-log.ts
@@ -6,17 +6,39 @@ export type UserLogCategory =
| 'wechat_auth'
| 'browse_store'
| 'redeem'
- | 'profile';
+ | 'profile'
+ | 'promo'
+ | 'customer_service'
+ | 'after_sale'
+ | 'error';
export const USER_LOG_EVENT_CATEGORIES: Record = {
- 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'],
+ login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail', 'session_start'],
+ browse_product: ['home_view', 'product_list_view', 'product_click', 'product_detail_view'],
+ order: ['order_confirm_view', 'order_submit', 'order_list_view', 'order_detail_view', 'order_cancel'],
+ pay: ['pay_page_view', 'pay_success', 'pay_fail'],
+ wechat_auth: [
+ 'wechat_login',
+ 'wechat_phone',
+ 'wechat_phone_login',
+ 'wechat_location',
+ 'wechat_album',
+ 'wechat_bind',
+ ],
browse_store: ['store_list_view', 'store_detail_view'],
- redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
- profile: ['profile_update', 'bind_phone'],
+ redeem: [
+ 'benefit_page_view',
+ 'benefit_detail_view',
+ 'benefit_redeem_start',
+ 'benefit_redeem_success',
+ 'redeem_code_view',
+ 'redeem_success_view',
+ ],
+ profile: ['profile_update', 'bind_phone', 'address_list_view', 'address_edit'],
+ promo: ['promo_touch', 'promo_share'],
+ customer_service: ['cs_contact'],
+ after_sale: ['after_sale_list_view', 'after_sale_apply'],
+ error: ['client_error'],
};
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
@@ -29,6 +51,10 @@ export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; lab
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
+ { value: 'promo', label: '推广' },
+ { value: 'customer_service', label: '客服' },
+ { value: 'after_sale', label: '售后' },
+ { value: 'error', label: '前端异常' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
@@ -59,3 +85,19 @@ export interface UserLogRowDto {
extraJson: Record | null;
createdAt: string;
}
+
+/** C 端埋点 extraJson 推荐字段(画像分析) */
+export type UserAnalyticsExtra = {
+ sessionId?: string;
+ cityCode?: string;
+ productId?: string;
+ skuId?: string;
+ storeId?: string;
+ orderId?: string;
+ amount?: number;
+ quantity?: number;
+ sourceType?: string;
+ sourceRefId?: string;
+ failReason?: string;
+ pagePath?: string;
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fe5e9f9..5b9d701 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,6 +72,9 @@ importers:
apps/h5-partner:
dependencies:
+ '@dukang/client-logging':
+ specifier: workspace:*
+ version: link:../../packages/client-logging
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
@@ -118,6 +121,9 @@ importers:
apps/h5-shop:
dependencies:
+ '@dukang/client-logging':
+ specifier: workspace:*
+ version: link:../../packages/client-logging
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
@@ -155,6 +161,9 @@ importers:
apps/h5-user:
dependencies:
+ '@dukang/client-logging':
+ specifier: workspace:*
+ version: link:../../packages/client-logging
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
@@ -277,6 +286,9 @@ importers:
'@babel/runtime':
specifier: ^7.24.4
version: 7.29.7
+ '@dukang/client-logging':
+ specifier: workspace:*
+ version: link:../../packages/client-logging
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
@@ -366,6 +378,12 @@ importers:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
+ packages/client-logging:
+ devDependencies:
+ typescript:
+ specifier: ^5.4.5
+ version: 5.9.3
+
packages/domain:
devDependencies:
typescript:
@@ -441,6 +459,9 @@ importers:
'@prisma/client':
specifier: ^5.18.0
version: 5.22.0(prisma@5.22.0)
+ '@sentry/node':
+ specifier: ^10.69.0
+ version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))
'@wecom/aibot-node-sdk':
specifier: ^1.0.7
version: 1.0.7
@@ -621,6 +642,17 @@ packages:
peerDependencies:
react: '>=16.9.0'
+ '@apm-js-collab/code-transformer-bundler-plugins@0.7.3':
+ resolution: {integrity: sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@apm-js-collab/code-transformer@0.18.1':
+ resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==}
+ hasBin: true
+
+ '@apm-js-collab/tracing-hooks@0.13.0':
+ resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==}
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -1680,6 +1712,48 @@ packages:
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
hasBin: true
+ '@opentelemetry/api-logs@0.220.0':
+ resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/api@1.9.1':
+ resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/core@2.10.0':
+ resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/instrumentation@0.220.0':
+ resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/resources@2.10.0':
+ resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/sdk-trace-base@2.10.0':
+ resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/sdk-trace@2.10.0':
+ resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/semantic-conventions@1.43.0':
+ resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
+ engines: {node: '>=14'}
+
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
@@ -2055,6 +2129,51 @@ packages:
cpu: [x64]
os: [win32]
+ '@sentry/conventions@0.16.0':
+ resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==}
+ engines: {node: '>=14'}
+
+ '@sentry/core@10.69.0':
+ resolution: {integrity: sha512-+uuqVEeiDzYuAKjZLqsROKXvRTbl/QeH0gfGRtpYib1cud4rAFWRIkFmcR7Jb7JGFYwmReyQotiTj/hcDszTZg==}
+ engines: {node: '>=18'}
+
+ '@sentry/node-core@10.69.0':
+ resolution: {integrity: sha512-IgArHczrZJxkgxoffHscj0NxQrG6kCazgmGQnlf3j58J1ec21YaUu8Tu+7G4Lo5tCiW3teQnwlKW1ttMXSqWRw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@opentelemetry/api': ^1.9.0
+ '@opentelemetry/core': ^1.30.1 || ^2.1.0
+ '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1'
+ '@opentelemetry/instrumentation': '>=0.57.1 <1'
+ '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@opentelemetry/core':
+ optional: true
+ '@opentelemetry/exporter-trace-otlp-http':
+ optional: true
+ '@opentelemetry/instrumentation':
+ optional: true
+ '@opentelemetry/sdk-trace-base':
+ optional: true
+
+ '@sentry/node@10.69.0':
+ resolution: {integrity: sha512-xEXA1YGIiTZbrW6MWV34uS6JGQuQg2ijTI0zed+FsJb9JZKPYel/GZK8Km26vfTVb+yCXFmWZNBesKegNcVdzg==}
+ engines: {node: '>=18'}
+
+ '@sentry/opentelemetry@10.69.0':
+ resolution: {integrity: sha512-3FyWV6YcEJuvLrlaKGE1dHXCI+1YO0a62w7PkwlRg8yp6K6YXkmdwu9GjqaYD+Ju4tm7uC7mHIsGFQMm0M7pqQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@opentelemetry/api': ^1.9.0
+ '@opentelemetry/core': ^1.30.1 || ^2.1.0
+ '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0
+
+ '@sentry/server-utils@10.69.0':
+ resolution: {integrity: sha512-0MwHrA8+nNvMIsqf8m3cXwCBlUjr6AS7N6CZvHJtY1DkqEvQqEbD5VIrhzEyHN/KMZIgQ8XeDCQRhjnXFQGRhg==}
+ engines: {node: '>=18'}
+
'@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -2941,6 +3060,10 @@ packages:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
+ astring@1.9.0:
+ resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
+ hasBin: true
+
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -3208,6 +3331,9 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
+ cjs-module-lexer@2.2.0:
+ resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
+
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
@@ -3389,6 +3515,7 @@ packages:
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
+ deprecated: v4 is no longer maintained, upgrade to v5
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
@@ -3653,6 +3780,9 @@ packages:
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-module-lexer@2.3.1:
+ resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
+
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
@@ -4264,6 +4394,10 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
+ import-in-the-middle@3.3.3:
+ resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==}
+ engines: {node: '>=18'}
+
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
@@ -4714,6 +4848,10 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
+ meriyah@6.1.4:
+ resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==}
+ engines: {node: '>=18.0.0'}
+
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
@@ -4784,6 +4922,9 @@ packages:
mobile-detect@1.4.5:
resolution: {integrity: sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==}
+ module-details-from-path@1.0.4:
+ resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
+
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
@@ -5657,6 +5798,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-in-the-middle@8.0.1:
+ resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
+ engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
+
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
@@ -5771,6 +5916,9 @@ packages:
resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==}
hasBin: true
+ semifies@1.0.0:
+ resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==}
+
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -6855,6 +7003,30 @@ snapshots:
resize-observer-polyfill: 1.5.1
throttle-debounce: 5.0.2
+ '@apm-js-collab/code-transformer-bundler-plugins@0.7.3':
+ dependencies:
+ '@apm-js-collab/code-transformer': 0.18.1
+ es-module-lexer: 2.3.1
+ magic-string: 0.30.21
+ module-details-from-path: 1.0.4
+
+ '@apm-js-collab/code-transformer@0.18.1':
+ dependencies:
+ '@types/estree': 1.0.9
+ astring: 1.9.0
+ esquery: 1.7.0
+ meriyah: 6.1.4
+ semifies: 1.0.0
+ source-map: 0.6.1
+
+ '@apm-js-collab/tracing-hooks@0.13.0':
+ dependencies:
+ '@apm-js-collab/code-transformer': 0.18.1
+ debug: 4.4.3
+ module-details-from-path: 1.0.4
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -8095,6 +8267,49 @@ snapshots:
transitivePeerDependencies:
- encoding
+ '@opentelemetry/api-logs@0.220.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+
+ '@opentelemetry/api@1.9.1': {}
+
+ '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.220.0
+ import-in-the-middle: 3.3.3
+ require-in-the-middle: 8.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/semantic-conventions@1.43.0': {}
+
'@parcel/watcher-android-arm64@2.5.6':
optional: true
@@ -8378,6 +8593,58 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
+ '@sentry/conventions@0.16.0': {}
+
+ '@sentry/core@10.69.0':
+ dependencies:
+ '@sentry/conventions': 0.16.0
+
+ '@sentry/node-core@10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
+ dependencies:
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.69.0
+ '@sentry/opentelemetry': 10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
+ import-in-the-middle: 3.3.3
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
+
+ '@sentry/node@10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.69.0
+ '@sentry/node-core': 10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
+ '@sentry/opentelemetry': 10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
+ '@sentry/server-utils': 10.69.0
+ import-in-the-middle: 3.3.3
+ transitivePeerDependencies:
+ - '@opentelemetry/core'
+ - '@opentelemetry/exporter-trace-otlp-http'
+ - supports-color
+
+ '@sentry/opentelemetry@10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.69.0
+
+ '@sentry/server-utils@10.69.0':
+ dependencies:
+ '@apm-js-collab/code-transformer-bundler-plugins': 0.7.3
+ '@apm-js-collab/tracing-hooks': 0.13.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.69.0
+ meriyah: 6.1.4
+ transitivePeerDependencies:
+ - supports-color
+
'@sideway/address@4.1.5':
dependencies:
'@hapi/hoek': 9.3.0
@@ -9629,6 +9896,8 @@ snapshots:
astral-regex@2.0.0: {}
+ astring@1.9.0: {}
+
asynckit@0.4.0: {}
autoprefixer@10.5.2(postcss@8.5.15):
@@ -9988,6 +10257,8 @@ snapshots:
chrome-trace-event@1.0.4: {}
+ cjs-module-lexer@2.2.0: {}
+
class-transformer@0.5.1: {}
class-validator@0.14.4:
@@ -10414,6 +10685,8 @@ snapshots:
es-module-lexer@1.7.0: {}
+ es-module-lexer@2.3.1: {}
+
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -11183,6 +11456,12 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
+ import-in-the-middle@3.3.3:
+ dependencies:
+ cjs-module-lexer: 2.2.0
+ es-module-lexer: 2.3.1
+ module-details-from-path: 1.0.4
+
imurmurhash@0.1.4: {}
inflight@1.0.6:
@@ -11609,6 +11888,8 @@ snapshots:
merge2@1.4.1: {}
+ meriyah@6.1.4: {}
+
methods@1.1.2: {}
micromatch@4.0.8:
@@ -11663,6 +11944,8 @@ snapshots:
mobile-detect@1.4.5: {}
+ module-details-from-path@1.0.4: {}
+
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
@@ -12589,6 +12872,13 @@ snapshots:
require-from-string@2.0.2: {}
+ require-in-the-middle@8.0.1:
+ dependencies:
+ debug: 4.4.3
+ module-details-from-path: 1.0.4
+ transitivePeerDependencies:
+ - supports-color
+
require-main-filename@2.0.0: {}
resize-observer-polyfill@1.5.1: {}
@@ -12742,6 +13032,8 @@ snapshots:
dependencies:
commander: 2.20.3
+ semifies@1.0.0: {}
+
semver@5.7.2: {}
semver@6.3.1: {}
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index 4b98bfc..30e74ab 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -112,3 +112,5 @@ SHIP_FROM_LAT=34.757
# Admin 概览「发布更新」:调用本机 deploy webhook(与 deploy/auto-release.env 中 SECRET 一致)
# DEPLOY_WEBHOOK_URL=http://127.0.0.1:8095/deploy
# DEPLOY_WEBHOOK_SECRET=change-me-to-a-long-random-string
+
+# Sentry DSN 在 HQ「系统设置 → 发布部署」维护(SENTRY_DSN);本地调试可临时写入 .env.local
diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json
index 38d99e1..4245b5a 100644
--- a/server/dukang-api/package.json
+++ b/server/dukang-api/package.json
@@ -32,6 +32,7 @@
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^6.1.3",
"@prisma/client": "^5.18.0",
+ "@sentry/node": "^10.69.0",
"@wecom/aibot-node-sdk": "^1.0.7",
"ali-oss": "^6.23.0",
"bullmq": "^5.12.0",
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index 123c3b5..c99c017 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -1,4 +1,4 @@
-import { Module } from '@nestjs/common';
+import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { PrismaModule } from './common/prisma/prisma.module';
@@ -23,6 +23,8 @@ import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module';
+import { LoggingModule } from './common/logging/logging.module';
+import { RequestIdMiddleware } from './common/logging/request-id.middleware';
@Module({
imports: [
@@ -52,8 +54,13 @@ import { WecomModule } from './integrations/wecom/wecom.module';
CityScopeModule,
CommonModule,
HqOperationModule,
+ LoggingModule,
CallbacksModule,
WecomModule,
],
})
-export class AppModule {}
+export class AppModule implements NestModule {
+ configure(consumer: MiddlewareConsumer) {
+ consumer.apply(RequestIdMiddleware).forRoutes('*');
+ }
+}
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.interceptor.ts b/server/dukang-api/src/common/hq-operation/hq-operation.interceptor.ts
index 8577875..ec23bbc 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.interceptor.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.interceptor.ts
@@ -5,8 +5,9 @@ import {
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
-import { Observable, tap } from 'rxjs';
+import { Observable, catchError, tap, throwError } from 'rxjs';
import type { AuthUser } from '../guards/jwt-auth.guard';
+import type { RequestWithId } from '../logging/request-id.middleware';
import { HQ_OPERATION_KEY, type HqOperationMeta } from './hq-operation.decorator';
import { HqOperationLogService } from './hq-operation-log.service';
@@ -61,42 +62,51 @@ export class HqOperationInterceptor implements NestInterceptor {
);
if (!meta) return next.handle();
- const req = context.switchToHttp().getRequest();
+ const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
return next.handle();
}
+ const writeLog = (status: 'SUCCESS' | 'FAILED', data?: unknown, errorMessage?: string) => {
+ const refId = meta.batch
+ ? 0n
+ : pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
+ ?? pickRefId(
+ meta.refIdField
+ ? (data as Record | null)?.[meta.refIdField]
+ : (data as Record | null)?.id,
+ )
+ ?? 0n;
+
+ const detail: Record = {
+ method: req.method,
+ path: req.originalUrl ?? req.url,
+ requestId: req.requestId,
+ };
+ if (meta.includeBody && req.body) {
+ detail.requestBody = sanitizeBody(req.body);
+ }
+ if (status === 'SUCCESS' && meta.includeResponse !== false && data != null) {
+ detail.response = summarizeResponse(data);
+ }
+ if (errorMessage) detail.error = errorMessage;
+
+ this.logService.logSafe({
+ hqAccountId: user.actorId,
+ action: meta.action,
+ refType: meta.refType,
+ refId,
+ status,
+ detail,
+ });
+ };
+
return next.handle().pipe(
- tap((data) => {
- const refId = meta.batch
- ? 0n
- : pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
- ?? pickRefId(
- meta.refIdField
- ? (data as Record | null)?.[meta.refIdField]
- : (data as Record | null)?.id,
- )
- ?? 0n;
-
- const detail: Record = {
- method: req.method,
- path: req.originalUrl ?? req.url,
- };
- if (meta.includeBody && req.body) {
- detail.requestBody = sanitizeBody(req.body);
- }
- if (meta.includeResponse !== false && data != null) {
- detail.response = summarizeResponse(data);
- }
-
- this.logService.logSafe({
- hqAccountId: user.actorId,
- action: meta.action,
- refType: meta.refType,
- refId,
- detail,
- });
+ tap((data) => writeLog('SUCCESS', data)),
+ catchError((err: { message?: string }) => {
+ writeLog('FAILED', undefined, err?.message ?? '操作失败');
+ return throwError(() => err);
}),
);
}
diff --git a/server/dukang-api/src/common/logging/logging.interceptor.ts b/server/dukang-api/src/common/logging/logging.interceptor.ts
new file mode 100644
index 0000000..ae83098
--- /dev/null
+++ b/server/dukang-api/src/common/logging/logging.interceptor.ts
@@ -0,0 +1,55 @@
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ Logger,
+ NestInterceptor,
+} from '@nestjs/common';
+import { Observable, tap } from 'rxjs';
+import type { Response } from 'express';
+import type { AuthUser } from '../guards/jwt-auth.guard';
+import type { RequestWithId } from './request-id.middleware';
+
+@Injectable()
+export class LoggingInterceptor implements NestInterceptor {
+ private readonly logger = new Logger('HTTP');
+
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const started = Date.now();
+ const http = context.switchToHttp();
+ const req = http.getRequest();
+ const res = http.getResponse();
+
+ return next.handle().pipe(
+ tap({
+ next: () => this.logLine(req, res.statusCode, Date.now() - started),
+ error: (err: { status?: number; message?: string }) => {
+ const status = err?.status ?? res.statusCode ?? 500;
+ this.logLine(req, status, Date.now() - started, err?.message);
+ },
+ }),
+ );
+ }
+
+ private logLine(
+ req: RequestWithId & { user?: AuthUser },
+ status: number,
+ latencyMs: number,
+ error?: string,
+ ) {
+ const line = {
+ requestId: req.requestId,
+ method: req.method,
+ path: req.originalUrl || req.url,
+ status,
+ latencyMs,
+ clientApp: req.headers['x-client-app'],
+ actorType: req.user?.actorType,
+ actorId: req.user?.actorId != null ? String(req.user.actorId) : undefined,
+ error: error?.slice(0, 200),
+ };
+ if (status >= 500) this.logger.error(JSON.stringify(line));
+ else if (status >= 400) this.logger.warn(JSON.stringify(line));
+ else this.logger.log(JSON.stringify(line));
+ }
+}
diff --git a/server/dukang-api/src/common/logging/logging.module.ts b/server/dukang-api/src/common/logging/logging.module.ts
new file mode 100644
index 0000000..3013f19
--- /dev/null
+++ b/server/dukang-api/src/common/logging/logging.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { RequestIdMiddleware } from './request-id.middleware';
+import { LoggingInterceptor } from './logging.interceptor';
+
+@Module({
+ providers: [RequestIdMiddleware, LoggingInterceptor],
+ exports: [RequestIdMiddleware, LoggingInterceptor],
+})
+export class LoggingModule {}
diff --git a/server/dukang-api/src/common/logging/request-id.middleware.ts b/server/dukang-api/src/common/logging/request-id.middleware.ts
new file mode 100644
index 0000000..e96001c
--- /dev/null
+++ b/server/dukang-api/src/common/logging/request-id.middleware.ts
@@ -0,0 +1,21 @@
+import { Injectable, NestMiddleware } from '@nestjs/common';
+import { randomUUID } from 'crypto';
+import type { Request, Response, NextFunction } from 'express';
+
+export const REQUEST_ID_HEADER = 'x-request-id';
+
+export type RequestWithId = Request & { requestId?: string };
+
+@Injectable()
+export class RequestIdMiddleware implements NestMiddleware {
+ use(req: RequestWithId, res: Response, next: NextFunction) {
+ const incoming = req.headers[REQUEST_ID_HEADER];
+ const requestId =
+ typeof incoming === 'string' && incoming.trim()
+ ? incoming.trim().slice(0, 64)
+ : randomUUID();
+ req.requestId = requestId;
+ res.setHeader('X-Request-Id', requestId);
+ next();
+ }
+}
diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts
index 8a52177..bc71bfb 100644
--- a/server/dukang-api/src/common/system-config/system-config.registry.ts
+++ b/server/dukang-api/src/common/system-config/system-config.registry.ts
@@ -159,6 +159,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
+ {
+ key: 'SENTRY_DSN',
+ label: 'Sentry DSN',
+ group: G.deploy,
+ type: 'password',
+ secret: true,
+ requiresRestart: true,
+ description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
+ },
{
key: 'WINERY_BANK_ACCOUNT_NAME',
diff --git a/server/dukang-api/src/integrations/sentry/sentry.bootstrap.ts b/server/dukang-api/src/integrations/sentry/sentry.bootstrap.ts
new file mode 100644
index 0000000..cacd2dc
--- /dev/null
+++ b/server/dukang-api/src/integrations/sentry/sentry.bootstrap.ts
@@ -0,0 +1,14 @@
+import * as Sentry from '@sentry/node';
+
+/** Optional Sentry bootstrap — reads SENTRY_DSN from system_config (preloaded) or .env. */
+export function initSentryIfConfigured() {
+ const dsn = process.env.SENTRY_DSN?.trim();
+ if (!dsn) return;
+
+ Sentry.init({
+ dsn,
+ environment: process.env.NODE_ENV ?? 'development',
+ tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0.2,
+ });
+ console.log('[sentry] initialized');
+}
diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts
index 758c77e..a27e178 100644
--- a/server/dukang-api/src/main.ts
+++ b/server/dukang-api/src/main.ts
@@ -7,8 +7,10 @@ import { json } from 'express';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
+import { LoggingInterceptor } from './common/logging/logging.interceptor';
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
import { AlertService } from './common/alert/alert.service';
+import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
async function bootstrap() {
const preloaded = await preloadSystemConfigEnv().catch((e) => {
@@ -18,6 +20,7 @@ async function bootstrap() {
if (preloaded > 0) {
console.log(`[config] loaded ${preloaded} keys from system_config`);
}
+ initSentryIfConfigured();
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.setGlobalPrefix('api/v1');
@@ -38,7 +41,7 @@ async function bootstrap() {
);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
- app.useGlobalInterceptors(new ResponseInterceptor());
+ app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
const port = process.env.PORT || 3000;
const cfg = loadAppConfig();
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
diff --git a/server/dukang-api/src/modules/analytics/analytics.controller.ts b/server/dukang-api/src/modules/analytics/analytics.controller.ts
index 82491c7..84d0e5a 100644
--- a/server/dukang-api/src/modules/analytics/analytics.controller.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.controller.ts
@@ -1,31 +1,74 @@
-import { Body, Controller, Post, UseGuards } from '@nestjs/common';
+import { BadRequestException, Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoTouchDto } from './dto/promo.dto';
-import { ActorType } from '@dukang/shared-types';
+import {
+ TrackPartnerEventsDto,
+ TrackStoreEventsDto,
+ TrackUserEventsDto,
+} from './dto/track-events.dto';
+import { ActorType, ClientApp } from '@dukang/shared-types';
+
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('events')
+ @UseGuards(OptionalJwtAuthGuard)
+ track(@CurrentUser() user: AuthUser | undefined, @Body() body: TrackUserEventsDto) {
+ const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
+ const clientApp = user?.clientApp ?? body.clientApp ?? ClientApp.USER_H5;
+ return this.analyticsService.trackBatchOptional(userId, clientApp, body.events, body.sessionId);
+ }
+
+ @Post('store-events')
@UseGuards(JwtAuthGuard)
- track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record }> }) {
- return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
+ trackStore(@CurrentUser() user: AuthUser, @Body() body: TrackStoreEventsDto) {
+ if (user.actorType !== ActorType.STORE) {
+ throw new BadRequestException('仅门店端可上报');
+ }
+ const storeId = body.storeId ? BigInt(body.storeId) : user.storeId;
+ if (storeId == null) throw new BadRequestException('缺少门店信息');
+ return this.analyticsService.trackStoreBatch(
+ user.actorId,
+ storeId,
+ user.clientApp,
+ body.events,
+ body.sessionId,
+ );
+ }
+
+ @Post('partner-events')
+ @UseGuards(JwtAuthGuard)
+ trackPartner(@CurrentUser() user: AuthUser, @Body() body: TrackPartnerEventsDto) {
+ if (user.actorType !== ActorType.PARTNER) {
+ throw new BadRequestException('仅合伙人端可上报');
+ }
+ return this.analyticsService.trackPartnerBatch(
+ user.actorId,
+ user.actorId,
+ user.clientApp,
+ body.events,
+ body.sessionId,
+ );
}
}
@Controller('promo')
export class PromoController {
- constructor(private readonly promoCodeService: PromoCodeService) {}
+ constructor(
+ private readonly promoCodeService: PromoCodeService,
+ private readonly analyticsService: AnalyticsService,
+ ) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
- touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
+ async touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
- return this.promoCodeService.touch(
+ const result = await this.promoCodeService.touch(
{
promoCode: dto.promoCode,
qrcodeId: dto.qrcodeId,
@@ -34,5 +77,29 @@ export class PromoController {
},
userId,
);
+
+ void this.analyticsService.trackBatchOptional(
+ userId,
+ user?.clientApp ?? ClientApp.USER_H5,
+ [
+ {
+ eventName: 'promo_touch',
+ params: {
+ sessionId: dto.sessionId,
+ promoCode: result.promoCode,
+ promoCodeId: result.promoCodeId,
+ channelName: result.channelName,
+ attributed: result.attributed,
+ sourceApplied: result.sourceApplied,
+ scanCounted: result.scanCounted,
+ sourceType: 'PROMO_CODE',
+ sourceRefId: result.promoCodeId,
+ },
+ },
+ ],
+ dto.sessionId,
+ );
+
+ return result;
}
-}
\ No newline at end of file
+}
diff --git a/server/dukang-api/src/modules/analytics/analytics.module.ts b/server/dukang-api/src/modules/analytics/analytics.module.ts
index ffc8ad9..75086e0 100644
--- a/server/dukang-api/src/modules/analytics/analytics.module.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.module.ts
@@ -3,10 +3,13 @@ import { IamModule } from '../iam/iam.module';
import { PromoModule } from '../promo/promo.module';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
+import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@Module({
- imports: [forwardRef(() => IamModule), PromoModule], controllers: [AnalyticsController, PromoController],
- providers: [AnalyticsService],
+ imports: [forwardRef(() => IamModule), PromoModule],
+ controllers: [AnalyticsController, PromoController],
+ providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts
index 052fe27..d4e1e2f 100644
--- a/server/dukang-api/src/modules/analytics/analytics.service.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.service.ts
@@ -1,13 +1,15 @@
import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
-import { serializeBigInt } from '../../common/decorators/current-user.decorator';
export type TrackEventInput = {
eventName: string;
pagePath?: string;
refType?: string;
refId?: bigint;
+ sessionId?: string;
+ sourceType?: string;
+ sourceRefId?: bigint;
extraJson?: Record;
};
@@ -17,35 +19,80 @@ export type TrackStoreEventInput = TrackEventInput & {
};
export type TrackPartnerEventInput = TrackEventInput & {
- /** 主账号 ID,用于合伙人维度聚合 */
partnerAccountId: bigint;
};
+type RawEvent = { eventName: string; params?: Record };
+
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
- async trackBatch(
- userId: bigint,
+ async trackBatchOptional(
+ userId: bigint | null | undefined,
clientApp: string,
- events: Array<{ eventName: string; params?: Record }>,
+ events: RawEvent[],
+ sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logUserAnalytics.createMany({
- data: events.map((e) => this.toRow(userId, clientApp, {
- eventName: e.eventName,
- extraJson: e.params,
- refType: typeof e.params?.refType === 'string' ? e.params.refType : undefined,
- refId: e.params?.refId != null ? BigInt(String(e.params.refId)) : undefined,
- pagePath: typeof e.params?.pagePath === 'string' ? e.params.pagePath : undefined,
- })),
+ data: events.map((e) =>
+ this.toUserRow(userId ?? null, clientApp, {
+ eventName: e.eventName,
+ ...this.parseParams(e.params, sessionId),
+ }),
+ ),
+ });
+ return { count: events.length };
+ }
+
+ async trackBatch(userId: bigint, clientApp: string, events: RawEvent[], sessionId?: string) {
+ return this.trackBatchOptional(userId, clientApp, events, sessionId);
+ }
+
+ async trackStoreBatch(
+ storeAccountId: bigint | undefined,
+ storeId: bigint,
+ clientApp: ClientApp | string,
+ events: RawEvent[],
+ sessionId?: string,
+ ) {
+ if (!events?.length) return { count: 0 };
+ await this.prisma.logStoreAnalytics.createMany({
+ data: events.map((e) =>
+ this.toStoreRow(storeAccountId, clientApp, {
+ storeId,
+ eventName: e.eventName,
+ ...this.parseParams(e.params, sessionId),
+ }),
+ ),
+ });
+ return { count: events.length };
+ }
+
+ async trackPartnerBatch(
+ actorAccountId: bigint | undefined,
+ partnerAccountId: bigint,
+ clientApp: ClientApp | string,
+ events: RawEvent[],
+ sessionId?: string,
+ ) {
+ if (!events?.length) return { count: 0 };
+ await this.prisma.logPartnerAnalytics.createMany({
+ data: events.map((e) =>
+ this.toPartnerRow(actorAccountId, clientApp, {
+ partnerAccountId,
+ eventName: e.eventName,
+ ...this.parseParams(e.params, sessionId),
+ }),
+ ),
});
return { count: events.length };
}
async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
await this.prisma.logUserAnalytics.create({
- data: this.toRow(userId, clientApp, event),
+ data: this.toUserRow(userId, clientApp, event),
});
}
@@ -53,7 +100,24 @@ export class AnalyticsService {
void this.trackOne(userId, clientApp, event).catch(() => {});
}
- async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
+ trackOneSafeOptional(
+ userId: bigint | null | undefined,
+ clientApp: ClientApp | string,
+ event: TrackEventInput,
+ ) {
+ void this.trackBatchOptional(
+ userId,
+ clientApp,
+ [{ eventName: event.eventName, params: this.eventToParams(event) }],
+ event.sessionId,
+ ).catch(() => {});
+ }
+
+ async trackStoreOne(
+ storeAccountId: bigint | undefined,
+ clientApp: ClientApp | string,
+ event: TrackStoreEventInput,
+ ) {
if (event.storeId == null) return;
await this.prisma.logStoreAnalytics.create({
data: this.toStoreRow(storeAccountId, clientApp, event),
@@ -83,14 +147,61 @@ export class AnalyticsService {
void this.trackPartnerOne(actorAccountId, clientApp, event).catch(() => {});
}
- private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
+ private parseParams(
+ params?: Record,
+ fallbackSessionId?: string,
+ ): Omit {
+ const p = params ?? {};
+ const sessionId =
+ typeof p.sessionId === 'string' ? p.sessionId.slice(0, 64) : fallbackSessionId?.slice(0, 64);
+ const pagePath = typeof p.pagePath === 'string' ? p.pagePath.slice(0, 128) : undefined;
+ const refType = typeof p.refType === 'string' ? p.refType : undefined;
+ const refId = p.refId != null ? BigInt(String(p.refId)) : undefined;
+ const sourceType = typeof p.sourceType === 'string' ? p.sourceType.slice(0, 32) : undefined;
+ const sourceRefId = p.sourceRefId != null ? BigInt(String(p.sourceRefId)) : undefined;
+ const {
+ sessionId: _s,
+ pagePath: _p,
+ refType: _rt,
+ refId: _ri,
+ sourceType: _st,
+ sourceRefId: _sr,
+ ...rest
+ } = p;
+ return {
+ sessionId,
+ pagePath,
+ refType,
+ refId,
+ sourceType,
+ sourceRefId,
+ extraJson: Object.keys(rest).length ? rest : undefined,
+ };
+ }
+
+ private eventToParams(event: TrackEventInput): Record {
+ return {
+ ...(event.pagePath ? { pagePath: event.pagePath } : {}),
+ ...(event.refType ? { refType: event.refType } : {}),
+ ...(event.refId != null ? { refId: event.refId.toString() } : {}),
+ ...(event.sessionId ? { sessionId: event.sessionId } : {}),
+ ...(event.sourceType ? { sourceType: event.sourceType } : {}),
+ ...(event.sourceRefId != null ? { sourceRefId: event.sourceRefId.toString() } : {}),
+ ...(event.extraJson ?? {}),
+ };
+ }
+
+ private toUserRow(userId: bigint | null, clientApp: ClientApp | string, event: TrackEventInput) {
return {
userId,
+ sessionId: event.sessionId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
pagePath: event.pagePath,
refType: event.refType,
refId: event.refId,
+ sourceType: event.sourceType,
+ sourceRefId: event.sourceRefId,
extraJson: event.extraJson as never,
};
}
@@ -126,4 +237,3 @@ export class AnalyticsService {
};
}
}
-
diff --git a/server/dukang-api/src/modules/analytics/dto/promo.dto.ts b/server/dukang-api/src/modules/analytics/dto/promo.dto.ts
index 7550d15..7b4b5bc 100644
--- a/server/dukang-api/src/modules/analytics/dto/promo.dto.ts
+++ b/server/dukang-api/src/modules/analytics/dto/promo.dto.ts
@@ -27,4 +27,8 @@ export class PromoTouchDto {
})
@IsBoolean()
countScan?: boolean;
+
+ @IsOptional()
+ @IsString()
+ sessionId?: string;
}
diff --git a/server/dukang-api/src/modules/analytics/dto/track-events.dto.ts b/server/dukang-api/src/modules/analytics/dto/track-events.dto.ts
new file mode 100644
index 0000000..1ba2f94
--- /dev/null
+++ b/server/dukang-api/src/modules/analytics/dto/track-events.dto.ts
@@ -0,0 +1,56 @@
+import { IsArray, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class AnalyticsEventDto {
+ @IsString()
+ @MaxLength(64)
+ eventName!: string;
+
+ @IsOptional()
+ params?: Record;
+}
+
+export class TrackUserEventsDto {
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => AnalyticsEventDto)
+ events!: AnalyticsEventDto[];
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(64)
+ sessionId?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(32)
+ clientApp?: string;
+}
+
+export class TrackStoreEventsDto {
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => AnalyticsEventDto)
+ events!: AnalyticsEventDto[];
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(64)
+ sessionId?: string;
+
+ @IsOptional()
+ @IsString()
+ storeId?: string;
+}
+
+export class TrackPartnerEventsDto {
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => AnalyticsEventDto)
+ events!: AnalyticsEventDto[];
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(64)
+ sessionId?: string;
+}
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index 512b414..b9fa7f5 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -1476,6 +1476,17 @@ export class AuthService {
data,
include: { avatar: true },
});
+ this.analyticsService.trackOneSafe(userId, 'USER_MINI', {
+ eventName: 'profile_update',
+ refType: 'USER',
+ refId: userId,
+ extraJson: {
+ fields: [
+ ...(data.nickname ? ['nickname'] : []),
+ ...(data.avatarResourceId ? ['avatar'] : []),
+ ],
+ },
+ });
return this.formatUserProfile(updated);
}
diff --git a/server/dukang-api/src/modules/ops/admin-domain-events.controller.ts b/server/dukang-api/src/modules/ops/admin-domain-events.controller.ts
new file mode 100644
index 0000000..3764159
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-domain-events.controller.ts
@@ -0,0 +1,36 @@
+import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
+import type { EventType } from '@prisma/client';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { AdminDomainEventsService } from './admin-domain-events.service';
+
+@Controller('admin/logs/domain-events')
+@UseGuards(HqAuthGuard)
+export class AdminDomainEventsController {
+ constructor(private readonly service: AdminDomainEventsService) {}
+
+ @Get()
+ list(
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ @Query('eventType') eventType?: EventType,
+ @Query('refType') refType?: string,
+ @Query('refId') refId?: string,
+ @Query('from') from?: string,
+ @Query('to') to?: string,
+ ) {
+ return this.service.list({
+ page: page ? Number(page) : undefined,
+ pageSize: pageSize ? Number(pageSize) : undefined,
+ eventType,
+ refType,
+ refId,
+ from,
+ to,
+ });
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-domain-events.service.ts b/server/dukang-api/src/modules/ops/admin-domain-events.service.ts
new file mode 100644
index 0000000..bcb4be8
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-domain-events.service.ts
@@ -0,0 +1,59 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import type { EventType, Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+
+const DOMAIN_EVENT_TYPES: EventType[] = [
+ 'ORDER_STATUS',
+ 'BENEFIT_LEDGER',
+ 'STORE_AUDIT',
+ 'TICKET_COLLAB',
+ 'PROMO_TOUCH',
+];
+
+@Injectable()
+export class AdminDomainEventsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(query: {
+ page?: number;
+ pageSize?: number;
+ eventType?: EventType;
+ refType?: string;
+ refId?: string;
+ from?: string;
+ to?: string;
+ }) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.CommonEventWhereInput = {
+ eventType: query.eventType ?? { in: DOMAIN_EVENT_TYPES },
+ };
+ if (query.refType) where.refType = query.refType;
+ if (query.refId) where.refId = BigInt(query.refId);
+ if (query.from || query.to) {
+ where.createdAt = {};
+ if (query.from) where.createdAt.gte = new Date(query.from);
+ if (query.to) where.createdAt.lte = new Date(query.to);
+ }
+
+ const [items, total] = await Promise.all([
+ this.prisma.commonEvent.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.commonEvent.count({ where }),
+ ]);
+ return serializeBigInt({ items, total, page, pageSize });
+ }
+
+ async detail(id: bigint) {
+ const row = await this.prisma.commonEvent.findUnique({ where: { id } });
+ if (!row || !DOMAIN_EVENT_TYPES.includes(row.eventType)) {
+ throw new NotFoundException('领域事件不存在');
+ }
+ return serializeBigInt(row);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts
index ea5b1a6..f9bce57 100644
--- a/server/dukang-api/src/modules/ops/admin-stores.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts
@@ -7,6 +7,7 @@ import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
+import { AnalyticsService } from '../analytics/analytics.service';
import type {
CreateStoreAccountDto,
CreateStoreDto,
@@ -49,6 +50,7 @@ export class AdminStoresService {
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
+ private readonly analyticsService: AnalyticsService,
) {}
async listStores(query: AdminStoresQueryDto) {
@@ -205,6 +207,19 @@ export class AdminStoresService {
},
});
+ if (updated.partnerAccountId) {
+ this.analyticsService.trackPartnerOneSafe(undefined, 'HQ_WEB', {
+ partnerAccountId: updated.partnerAccountId,
+ eventName: dto.approved ? 'partner_store_audit_approved' : 'partner_store_audit_rejected',
+ refType: 'STORE',
+ refId: id,
+ extraJson: {
+ storeId: id.toString(),
+ remark: dto.remark?.trim() || null,
+ },
+ });
+ }
+
return serializeBigInt({
...updated,
notifyHint: dto.approved
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 26bf70f..21a2da8 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { IamModule } from '../iam/iam.module';
import { TradeModule } from '../trade/trade.module';
+import { AnalyticsModule } from '../analytics/analytics.module';
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { StoreModule } from '../store/store.module';
import { AdminDashboardController } from './admin-dashboard.controller';
@@ -71,9 +72,11 @@ import { AdminLlmConfigsService } from './admin-llm-configs.service';
import { AdminKnowledgeBasesController } from './admin-knowledge-bases.controller';
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
+import { AdminDomainEventsController } from './admin-domain-events.controller';
+import { AdminDomainEventsService } from './admin-domain-events.service';
@Module({
- imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
+ imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
controllers: [
AdminDashboardController,
AdminDeployController,
@@ -100,6 +103,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminStoreLogsController,
AdminPartnerLogsController,
AdminHqLogsController,
+ AdminDomainEventsController,
AdminOssLogsController,
AdminTicketsController,
AdminSupportTicketsController,
@@ -133,6 +137,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminStoreLogsService,
AdminPartnerLogsService,
AdminHqLogsService,
+ AdminDomainEventsService,
AdminOssLogsService,
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 441af74..2a16df4 100644
--- a/server/dukang-api/src/modules/redeem/redeem.service.ts
+++ b/server/dukang-api/src/modules/redeem/redeem.service.ts
@@ -519,6 +519,17 @@ export class RedeemService {
REDEEM_TOKEN_TTL_SECONDS,
);
+ this.analyticsService.trackOneSafe(userId, 'USER_H5', {
+ eventName: 'benefit_redeem_start',
+ refType: body.storeId ? 'STORE' : 'BENEFIT_COUPON',
+ refId: body.storeId ? BigInt(body.storeId) : primaryCouponId,
+ extraJson: {
+ amount: body.amount,
+ storeId: body.storeId ?? null,
+ couponId: primaryCouponId.toString(),
+ },
+ });
+
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
}
diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts
index 171251f..71acbd2 100644
--- a/server/dukang-api/src/modules/settlement/settlement.controller.ts
+++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts
@@ -29,6 +29,11 @@ export class SettlementController {
return this.settlementService.listPartnerBills(user.actorId);
}
+ @Get('bills/:id')
+ billDetail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
+ return this.settlementService.getPartnerBill(user.actorId, BigInt(id));
+ }
+
@Post('bills/batch-confirm')
batchConfirm(@CurrentUser() user: AuthUser, @Body() body: { ids: string[] }) {
return this.settlementService.batchPartnerConfirmBills(user.actorId, body.ids ?? []);
diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts
index 163c15a..08026cf 100644
--- a/server/dukang-api/src/modules/settlement/settlement.service.ts
+++ b/server/dukang-api/src/modules/settlement/settlement.service.ts
@@ -1169,6 +1169,22 @@ export class SettlementService {
return serializeBigInt(bills);
}
+ async getPartnerBill(partnerAccountId: bigint, billId: bigint) {
+ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
+ const bill = await this.prisma.partnerBill.findFirst({
+ where: { id: billId, partnerAccountId: primary.id },
+ });
+ if (!bill) throw new NotFoundException('账单不存在');
+ this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
+ partnerAccountId: primary.id,
+ eventName: 'partner_bill_detail_view',
+ refType: 'PARTNER_BILL',
+ refId: billId,
+ extraJson: { billId: billId.toString(), status: bill.status },
+ });
+ return serializeBigInt(bill);
+ }
+
async listAdminPartnerBills(query: {
page?: number;
pageSize?: number;
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index bd0c393..5209715 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -310,6 +310,12 @@ export class TradeService {
orderNo: order.orderNo,
userId,
});
+ this.analyticsService.trackOneSafe(userId, clientApp ?? 'USER_H5', {
+ eventName: 'pay_fail',
+ refType: 'ORDER',
+ refId: orderId,
+ extraJson: { orderId: orderId.toString(), failReason: WECHAT_AUTH_REQUIRED, stage: 'auth' },
+ });
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
}
const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5';
@@ -317,10 +323,17 @@ export class TradeService {
try {
payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
} catch (e) {
- this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '拉起支付失败', {
+ const failReason = e instanceof Error ? e.message : '拉起支付失败';
+ this.payRedeemAnomaly.onPayFail(failReason, {
orderNo: order.orderNo,
userId,
});
+ this.analyticsService.trackOneSafe(userId, clientApp ?? 'USER_H5', {
+ eventName: 'pay_fail',
+ refType: 'ORDER',
+ refId: orderId,
+ extraJson: { orderId: orderId.toString(), failReason, stage: 'prepay' },
+ });
throw e;
}
diff --git a/杜康好客-v3-埋点规范.md b/杜康好客-v3-埋点规范.md
new file mode 100644
index 0000000..7929a50
--- /dev/null
+++ b/杜康好客-v3-埋点规范.md
@@ -0,0 +1,86 @@
+# 杜康好客 · V3 埋点规范
+
+> **版本**:2026-08-03
+> **存储**:C 端 → `log_user_analytics`;门店 → `log_store_analytics`;合伙人 → `log_partner_analytics`
+> **契约**:[`packages/shared-types/src/*-log.ts`](../packages/shared-types/src/)
+> **查询**:admin-web `/logs/users` · `/logs/stores` · `/logs/partners`
+
+## 原则
+
+1. 端侧 **page_view / 点击** 走客户端 `track()`;业务结果(支付成功、核销确认)走后端 `AnalyticsService.track*Safe`
+2. 所有客户端事件携带 `sessionId`(localStorage `dukang_session_id`),支持匿名漏斗
+3. `extraJson` 不含密码/令牌;手机号脱敏
+4. eventName 必须先登记在 shared-types taxonomy,再 emit
+
+## API
+
+| 端 | 路由 | 鉴权 |
+|----|------|------|
+| C 端 | `POST /api/v1/analytics/events` | OptionalJwt |
+| 门店 | `POST /api/v1/analytics/store-events` | Jwt + STORE |
+| 合伙人 | `POST /api/v1/analytics/partner-events` | Jwt + PARTNER |
+| 推广 | `POST /api/v1/promo/touch` | OptionalJwt(双写 `promo_touch` 埋点) |
+
+## 完整 eventName 清单
+
+见 shared-types:
+
+- [`user-log.ts`](../packages/shared-types/src/user-log.ts) — C 端 13 个 category
+- [`store-log.ts`](../packages/shared-types/src/store-log.ts) — 门店 8 个 category
+- [`partner-log.ts`](../packages/shared-types/src/partner-log.ts) — 合伙人 9 个 category
+
+## extraJson 字段约定
+
+### C 端(UserAnalyticsExtra)
+
+| 字段 | 说明 |
+|------|------|
+| sessionId | 会话 ID,漏斗串联 |
+| cityCode | 开城/地域 |
+| productId / skuId | 商品偏好 |
+| storeId | 门店偏好 |
+| orderId | 订单归因 |
+| amount / quantity | 客单价 |
+| sourceType / sourceRefId | 获客渠道 |
+| failReason | 失败原因 |
+| pagePath | 页面路径 |
+
+### 门店(StoreAnalyticsExtra)
+
+| 字段 | 说明 |
+|------|------|
+| redeemChannel | scan / phone / pending |
+| amount | 核销金额 |
+| durationMs | 耗时 |
+| failReason | 失败原因 |
+
+### 合伙人(PartnerAnalyticsExtra)
+
+| 字段 | 说明 |
+|------|------|
+| storeId / orderId / billId | 业务关联 |
+| cityCode | 城市维度 |
+
+## 转化漏斗(C 端)
+
+```
+session_start → home_view → product_detail_view → order_confirm_view
+ → order_submit → pay_page_view → pay_success
+ → benefit_redeem_start → redeem_code_view → benefit_redeem_success
+```
+
+## 核销漏斗(门店)
+
+```
+store_home_view → store_redeem_scan_start → store_redeem_preview
+ → store_redeem_confirm | store_redeem_confirm_fail
+```
+
+## 合伙人经营漏斗
+
+```
+partner_home_view → partner_store_create_view → partner_store_create
+ → partner_store_audit_approved
+partner_order_list_view → partner_order_ship
+partner_bills_view → partner_bill_detail_view → partner_bill_confirm
+```