feat(analytics): persona logging upgrade and Sentry system config

Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 22:30:02 +08:00
parent ab10431001
commit 89fd333702
91 changed files with 1805 additions and 136 deletions
+2
View File
@@ -38,6 +38,7 @@ import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage';
import DomainEventsPage from './pages/DomainEventsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
@@ -120,6 +121,7 @@ export default function App() {
<Route path="/logs/partners" element={<PartnerLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
@@ -122,6 +122,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/logs/partners', label: '合伙人日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
{ key: '/logs/domain-events', label: '领域事件' },
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
@@ -182,6 +183,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/partners': 'logs',
'/logs/hq': 'logs',
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
+9 -42
View File
@@ -1,45 +1,12 @@
export type UserLogCategory =
| 'login'
| 'browse_product'
| 'order'
| 'pay'
| 'wechat_auth'
| 'browse_store'
| 'redeem'
| 'profile';
export {
USER_LOG_CATEGORY_OPTIONS,
resolveUserLogCategory,
eventNamesForUserLogCategory,
type UserLogCategory,
} from '@dukang/shared-types';
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
browse_store: ['store_list_view', 'store_detail_view'],
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
profile: ['profile_update', 'bind_phone'],
};
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'browse_product', label: '浏览商品' },
{ value: 'order', label: '下单' },
{ value: 'pay', label: '支付' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
[UserLogCategory, readonly string[]]
>) {
if (events.includes(eventName)) return category;
}
return null;
}
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
) as Record<string, string>;
);
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
type Row = {
id: string;
eventType: string;
refType: string;
refId: string;
status?: string | null;
param1?: string | null;
param2?: string | null;
param3?: string | null;
remark?: string | null;
createdAt: string;
};
const EVENT_OPTIONS = [
{ value: '', label: '全部领域事件' },
{ value: 'ORDER_STATUS', label: '订单状态' },
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'TICKET_COLLAB', label: '工单协作' },
{ value: 'PROMO_TOUCH', label: '推广触达' },
];
export default function DomainEventsPage() {
const [params, setParams] = useSearchParams();
const eventType = params.get('eventType') ?? '';
const refType = params.get('refType') ?? '';
const refId = params.get('refId') ?? '';
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Row | null>(null);
const load = useCallback(() => {
setLoading(true);
const q = new URLSearchParams();
if (eventType) q.set('eventType', eventType);
if (refType) q.set('refType', refType);
if (refId) q.set('refId', refId);
q.set('page', params.get('page') ?? '1');
q.set('pageSize', '20');
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
`/admin/logs/domain-events?${q.toString()}`,
)
.then(setData)
.finally(() => setLoading(false));
}, [eventType, refType, refId, params]);
useEffect(() => {
load();
}, [load]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
{
title: '操作',
width: 80,
render: (_, r) => (
<a
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
</a>
),
},
];
return (
<Card title="领域事件">
<Space wrap style={{ marginBottom: 16 }}>
<Select
style={{ width: 180 }}
value={eventType}
options={EVENT_OPTIONS}
onChange={(v) => {
const next = new URLSearchParams(params);
if (v) next.set('eventType', v);
else next.delete('eventType');
next.set('page', '1');
setParams(next);
}}
/>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: data?.page ?? 1,
pageSize: data?.pageSize ?? 20,
total: data?.total ?? 0,
onChange: (p) => {
const next = new URLSearchParams(params);
next.set('page', String(p));
setParams(next);
},
}}
/>
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</Card>
);
}
+39 -2
View File
@@ -17,7 +17,7 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
@@ -30,6 +30,14 @@ type UserOrderRow = {
createdAt: string;
};
type UserBehaviorLog = {
id: string;
eventName: string;
clientApp?: string | null;
createdAt: string;
extraJson?: Record<string, unknown> | null;
};
type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null;
@@ -72,6 +80,7 @@ export default function UsersPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<UserDetail | null>(null);
const [behaviorLogs, setBehaviorLogs] = useState<UserBehaviorLog[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState('');
@@ -123,8 +132,14 @@ export default function UsersPage() {
}, [location.state, location.pathname, navigate]);
async function openDetail(id: string) {
const res = await request<UserDetail>(`/admin/users/${id}`);
const [res, logs] = await Promise.all([
request<UserDetail>(`/admin/users/${id}`),
request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch(
() => ({ items: [] as UserBehaviorLog[] }),
),
]);
setDetail(res);
setBehaviorLogs(logs.items ?? []);
setDrawerOpen(true);
}
@@ -437,6 +452,28 @@ export default function UsersPage() {
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
线 {behaviorLogs.length}
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ y: 200 }}
dataSource={behaviorLogs}
locale={{ emptyText: '暂无行为日志' }}
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v) => fmtTime(v) },
{
title: '分类',
width: 100,
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
},
{ title: '事件', dataIndex: 'eventName' },
{ title: '端', dataIndex: 'clientApp', width: 90 },
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{detail.orders?.length ?? 0}
</Typography.Title>
+1
View File
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+16
View File
@@ -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<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPartnerPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPartnerPageView } from './analytics';
export function usePartnerPageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPartnerPageView(eventName, params);
}, [eventName, params]);
}
+7 -2
View File
@@ -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(
<React.StrictMode>
+2
View File
@@ -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<PartnerBillDto[]>([]);
+2
View File
@@ -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);
@@ -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);
@@ -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<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
@@ -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);
@@ -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(), []);
@@ -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<PartnerStaffItem[]>([]);
const [q, setQ] = useState('');
@@ -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();
@@ -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);
@@ -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();
@@ -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<string | undefined>();
const [data, setData] = useState<PartnerWeeklyReportResponse | null>(null);
+1
View File
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+26
View File
@@ -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<string, unknown>) {
tracker.track(eventName, params);
}
export function trackStorePageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackStorePageView } from './analytics';
export function useStorePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackStorePageView(eventName, params);
}, [eventName, params]);
}
+7 -2
View File
@@ -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(
<React.StrictMode>
+4
View File
@@ -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;
+2
View File
@@ -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();
+2
View File
@@ -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<PackageFormItem[]>([emptyPackage()]);
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
@@ -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('');
+2
View File
@@ -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<Array<Record<string, unknown>>>([]);
const [range, setRange] = useState<RangeKey>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
@@ -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('');
+2
View File
@@ -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();
+2
View File
@@ -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<StoreWithdrawSummaryDto | null>(null);
const [items, setItems] = useState<StoreWithdrawRequestDto[]>([]);
+1
View File
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+17 -12
View File
@@ -1,16 +1,21 @@
import { createUserTracker, getSessionId } from '@dukang/client-logging';
import { apiBase } from './api';
export function track(eventName: string, params?: Record<string, unknown>) {
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<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
export function initUserAnalytics() {
tracker.trackSessionStart();
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPageView } from './analytics';
export function usePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPageView(eventName, params);
}, [eventName, params]);
}
+6
View File
@@ -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(
<React.StrictMode>
<BrowserRouter basename={getRouterBasename()}>
@@ -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);
@@ -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<Address[]>([]);
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
@@ -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<string, string> = {
};
export default function AfterSaleListPage() {
usePageView('after_sale_list_view');
const navigate = useNavigate();
const [items, setItems] = useState<TicketRow[]>([]);
const [loading, setLoading] = useState(true);
+3
View File
@@ -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') || '';
@@ -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<string, unknown>; ledgers: Array<Record<string, unknown>> } | null>(null);
useEffect(() => {
+2
View File
@@ -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<HTMLElement>(null);
const [summary, setSummary] = useState<BenefitSummary | null>(null);
@@ -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<Order | null>(null);
const [showCs, setShowCs] = useState(false);
+2
View File
@@ -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<string, unknown>) {
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<Record<string, unknown>> }>({ list: [] });
const navigate = useNavigate();
+8
View File
@@ -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);
}
+4 -1
View File
@@ -155,7 +155,10 @@ export default function ProductDetailPage() {
<button
type="button"
className="product-detail-buy-btn"
onClick={() => navigate(`/order/confirm?productId=${id}&qty=2`)}
onClick={() => {
track('product_click', { refType: 'PRODUCT', refId: id, productId: id });
navigate(`/order/confirm?productId=${id}&qty=2`);
}}
>
</button>
@@ -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';
+2
View File
@@ -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;
@@ -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);
+1
View File
@@ -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:*",
+22
View File
@@ -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<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
export function initUserAnalytics() {
tracker.trackSessionStart();
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPageView } from './analytics';
export function usePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPageView(eventName, params);
}, [eventName, params]);
}
+5
View File
@@ -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<ReturnType<typeof setTimeout> | 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) => {
@@ -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<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false);
@@ -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<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -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<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
+22
View File
@@ -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"
}
}
+121
View File
@@ -0,0 +1,121 @@
import { getSessionId } from './session';
export type TrackParams = Record<string, unknown>;
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<string, string> = {
'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 };
}
@@ -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<string, unknown>;
}) => {
const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
const headers: Record<string, string> = {
'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<string, string> = {
'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(() => {});
}
+17
View File
@@ -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';
+18
View File
@@ -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();
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../domain/tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2020", "DOM"],
"declaration": true
},
"include": ["src/**/*"]
}
+28 -1
View File
@@ -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<PartnerLogCategory, readonly string[]> = {
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<PartnerLogCategory, readonly s
'partner_store_audit_approved',
'partner_store_audit_rejected',
],
shipping: ['partner_order_ship', 'partner_delivery_advance'],
shipping: ['partner_order_ship', 'partner_delivery_advance', 'partner_proxy_order_create'],
settlement: ['partner_bill_view', 'partner_bill_detail_view', 'partner_bill_confirm'],
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
};
@@ -33,6 +48,7 @@ export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | '
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'page_view', label: '页面浏览' },
{ value: 'account_ops', label: '子账号管理' },
{ value: 'store_ops', label: '门店操作' },
{ value: 'shipping', label: '发货/配送' },
@@ -44,6 +60,7 @@ export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string
'': '全部',
login: '登录',
wechat_auth: '微信授权',
page_view: '页面浏览',
account_ops: '子账号管理',
store_ops: '门店操作',
shipping: '发货/配送',
@@ -82,3 +99,13 @@ export interface PartnerLogRowDto {
extraJson: Record<string, unknown> | null;
createdAt: string;
}
export type PartnerAnalyticsExtra = {
sessionId?: string;
partnerAccountId?: string;
storeId?: string;
orderId?: string;
billId?: string;
cityCode?: string;
pagePath?: string;
};
+46 -4
View File
@@ -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<StoreLogCategory, readonly string[]> = {
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<StoreLogCategory, readonly strin
'store_redeem_phone_lookup_sms',
'store_redeem_phone_balance',
'store_redeem_phone_prepare',
'store_redeem_scan_start',
],
payout: ['store_payout_created', 'store_payout_paid'],
store_ops: ['store_status_change'],
payout: ['store_payout_created', 'store_payout_paid', 'store_withdraw_applied', 'store_withdraw_paid'],
store_ops: [
'store_status_change',
'store_staff_create',
'store_staff_update',
'store_staff_delete',
'store_staff_permission_update',
],
package: ['store_package_apply', 'store_package_audit_view'],
};
export const STORE_LOG_CATEGORY_OPTIONS: Array<{ value: StoreLogCategory | ''; label: string }> = [
{ 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<StoreLogCategory | '', string> = {
'': '全部',
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<string, unknown> | null;
createdAt: string;
}
export type StoreAnalyticsExtra = {
sessionId?: string;
storeId?: string;
storeAccountId?: string;
redeemChannel?: 'scan' | 'phone' | 'pending';
amount?: number;
failReason?: string;
durationMs?: number;
pagePath?: string;
};
+50 -8
View File
@@ -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<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
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<string, unknown> | 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;
};
+292
View File
@@ -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: {}
+2
View File
@@ -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
+1
View File
@@ -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",
+9 -2
View File
@@ -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('*');
}
}
@@ -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<RequestWithId & { user?: AuthUser }>();
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<string, unknown> | null)?.[meta.refIdField]
: (data as Record<string, unknown> | null)?.id,
)
?? 0n;
const detail: Record<string, unknown> = {
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<string, unknown> | null)?.[meta.refIdField]
: (data as Record<string, unknown> | null)?.id,
)
?? 0n;
const detail: Record<string, unknown> = {
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);
}),
);
}
@@ -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<unknown> {
const started = Date.now();
const http = context.switchToHttp();
const req = http.getRequest<RequestWithId & { user?: AuthUser }>();
const res = http.getResponse<Response>();
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));
}
}
@@ -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 {}
@@ -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();
}
}
@@ -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',
@@ -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');
}
+4 -1
View File
@@ -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<NestExpressApplication>(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';
@@ -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<string, unknown> }> }) {
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;
}
}
}
@@ -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 {}
@@ -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<string, unknown>;
};
@@ -17,35 +19,80 @@ export type TrackStoreEventInput = TrackEventInput & {
};
export type TrackPartnerEventInput = TrackEventInput & {
/** 主账号 ID,用于合伙人维度聚合 */
partnerAccountId: bigint;
};
type RawEvent = { eventName: string; params?: Record<string, unknown> };
@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<string, unknown> }>,
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<string, unknown>,
fallbackSessionId?: string,
): Omit<TrackEventInput, 'eventName'> {
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<string, unknown> {
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 {
};
}
}
@@ -27,4 +27,8 @@ export class PromoTouchDto {
})
@IsBoolean()
countScan?: boolean;
@IsOptional()
@IsString()
sessionId?: string;
}
@@ -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<string, unknown>;
}
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;
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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
@@ -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,
@@ -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 };
}
@@ -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 ?? []);
@@ -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;
@@ -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;
}
+86
View File
@@ -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
```