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