合伙人端登录验证调整还有日志落地
This commit is contained in:
@@ -28,6 +28,7 @@ import UserLogsPage from './pages/UserLogsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
import StoreLogsPage from './pages/StoreLogsPage';
|
||||
import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -67,6 +68,7 @@ export default function App() {
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||
<Route path="/logs/hq" element={<HqLogsPage />} />
|
||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
|
||||
@@ -74,6 +74,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
children: [
|
||||
{ key: '/logs/users', label: '用户日志' },
|
||||
{ key: '/logs/stores', label: '商户日志' },
|
||||
{ key: '/logs/partners', label: '合伙人日志' },
|
||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||
{ key: '/logs/third-party', label: '第三方日志' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
} from '../lib/partner-log';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
export default function PartnerLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
partnerId: searchParams.get('partnerId') ?? '',
|
||||
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
companyName: searchParams.get('companyName') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/logs/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 180,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.companyName || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.partnerId}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 150,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.accountName || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.partnerAccountId || '—'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (v: PartnerLogCategory | null, r) => (
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
</Typography.Title>
|
||||
<Segmented
|
||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID">
|
||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="eventName" label="事件名">
|
||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({
|
||||
partnerId: '',
|
||||
partnerAccountId: '',
|
||||
phone: '',
|
||||
companyName: '',
|
||||
eventName: '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(detail).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,12 +23,26 @@ function loadRememberedPhone(): { phone: string; remember: boolean } {
|
||||
}
|
||||
}
|
||||
|
||||
function formatPartnerError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '操作失败';
|
||||
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
||||
return '未找到合伙人账号';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const remembered = loadRememberedPhone();
|
||||
const [step, setStep] = useState<'phone' | 'code'>('phone');
|
||||
const [phone, setPhone] = useState(remembered.phone || '13700000001');
|
||||
const [confirmedProfile, setConfirmedProfile] = useState<{
|
||||
maskedPhone: string;
|
||||
name?: string;
|
||||
companyName?: string;
|
||||
} | null>(null);
|
||||
const [code, setCode] = useState('123456');
|
||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
@@ -77,6 +91,37 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function backToPhoneStep() {
|
||||
setStep('phone');
|
||||
setConfirmedProfile(null);
|
||||
setCode('');
|
||||
setMsg('');
|
||||
setCodeCooldown(0);
|
||||
}
|
||||
|
||||
async function confirmPhone() {
|
||||
if (!ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const data = await request<{ ok: boolean; maskedPhone: string; name?: string; companyName?: string }>(
|
||||
'PARTNER_H5',
|
||||
'/partner/auth/phone/check',
|
||||
{ method: 'POST', body: JSON.stringify({ phone }) },
|
||||
);
|
||||
setConfirmedProfile({
|
||||
maskedPhone: data.maskedPhone,
|
||||
name: data.name,
|
||||
companyName: data.companyName,
|
||||
});
|
||||
setStep('code');
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
@@ -97,7 +142,7 @@ export default function LoginPage() {
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
setMsg(formatPartnerError(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +151,6 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
});
|
||||
const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
@@ -123,7 +164,7 @@ export default function LoginPage() {
|
||||
}
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -203,39 +244,83 @@ export default function LoginPage() {
|
||||
<main className="partner-auth-card">
|
||||
<h2 className="partner-auth-card-title">城市合伙人登录</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
|
||||
<input className="partner-input" type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
<input className="partner-input" type="text" placeholder="验证码" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="partner-remember-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberAccount}
|
||||
onChange={(e) => setRememberAccount(e.target.checked)}
|
||||
/>
|
||||
<span>记住账号</span>
|
||||
</label>
|
||||
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
{step === 'phone' ? (
|
||||
<>
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
setMsg('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="partner-btn-primary" onClick={confirmPhone} disabled={loading}>
|
||||
<span>{loading ? '校验中...' : '下一步'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="partner-glass-card" style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{confirmedProfile?.name || '合伙人账号'}
|
||||
{confirmedProfile?.companyName ? (
|
||||
<span className="text-muted body-md" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||
{confirmedProfile.companyName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted body-md" style={{ marginTop: 4 }}>
|
||||
{confirmedProfile?.maskedPhone || phone}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="partner-link" onClick={backToPhoneStep} style={{ flexShrink: 0 }}>
|
||||
修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
<input className="partner-input" type="text" placeholder="验证码" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="partner-remember-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberAccount}
|
||||
onChange={(e) => setRememberAccount(e.target.checked)}
|
||||
/>
|
||||
<span>记住账号</span>
|
||||
</label>
|
||||
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
|
||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
||||
</svg>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
|
||||
</button>
|
||||
{step === 'phone' && (
|
||||
<>
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
|
||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
||||
</svg>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<label className="partner-checkbox-row">
|
||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
||||
<span>
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs",
|
||||
"smoke": "node scripts/smoke-v3.mjs",
|
||||
"smoke:v3": "node scripts/smoke-v3.mjs",
|
||||
"smoke:prev1": "node scripts/smoke-prev1.mjs"
|
||||
"smoke:prev1": "node scripts/smoke-prev1.mjs",
|
||||
"smoke:partner-auth": "node scripts/test-partner-auth.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -11,4 +11,5 @@ export * from './ops';
|
||||
export * from './ticket';
|
||||
export * from './user-log';
|
||||
export * from './store-log';
|
||||
export * from './partner-log';
|
||||
export * from './promo';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type PartnerLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'store_ops'
|
||||
| 'shipping'
|
||||
| 'settlement';
|
||||
|
||||
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'],
|
||||
store_ops: ['partner_store_create', 'partner_store_status_change'],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
|
||||
};
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'shipping', label: '发货/配送' },
|
||||
{ value: 'settlement', label: '结算' },
|
||||
];
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '微信授权',
|
||||
store_ops: '门店操作',
|
||||
shipping: '发货/配送',
|
||||
settlement: '结算',
|
||||
};
|
||||
|
||||
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
|
||||
for (const [category, events] of Object.entries(PARTNER_LOG_EVENT_CATEGORIES) as Array<
|
||||
[PartnerLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function eventNamesForPartnerLogCategory(category: string): string[] | undefined {
|
||||
if (!category) return undefined;
|
||||
return [...(PARTNER_LOG_EVENT_CATEGORIES[category as PartnerLogCategory] ?? [])];
|
||||
}
|
||||
|
||||
export interface PartnerLogRowDto {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
+10
-1
@@ -193,7 +193,16 @@ async function main() {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
console.log('11. Partner bill');
|
||||
console.log('11. Partner phone gate');
|
||||
const unknownPartner = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999' }),
|
||||
});
|
||||
if (!unknownPartner.includes('未找到合伙人账号')) {
|
||||
throw new Error(`Expected partner phone gate, got: ${unknownPartner}`);
|
||||
}
|
||||
|
||||
console.log('12. Partner bill');
|
||||
const partners = await req('HQ_WEB', '/admin/partners', { token: admin.accessToken });
|
||||
const partnerId = partners.items?.[0]?.id;
|
||||
if (partnerId) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
const DEFAULT_MOCK_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-redis';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchJson(path, options = {}) {
|
||||
const res = await fetch(`${API}${path}`, options);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function readSmsCodeFromRedis(phone, scene) {
|
||||
const key = `dukang:sms:${scene}:${phone}`;
|
||||
try {
|
||||
const code = execSync(`docker exec ${REDIS_CONTAINER} redis-cli GET "${key}"`, {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (code && code !== '(nil)') return code;
|
||||
} catch {
|
||||
/* docker/redis unavailable */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadClientConfig() {
|
||||
const json = await fetchJson('/common/client-config');
|
||||
if (json.code !== 0) throw new Error(`client-config: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function resolveSmsCode(phone, scene) {
|
||||
const cfg = await loadClientConfig();
|
||||
if (cfg.mockSms) return DEFAULT_MOCK_CODE;
|
||||
|
||||
const fromRedis = readSmsCodeFromRedis(phone, scene);
|
||||
if (fromRedis) return fromRedis;
|
||||
|
||||
throw new Error(
|
||||
`SMS code not found for ${phone} (${scene}); enable MOCK_SMS or ensure Redis is reachable`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendSmsOnce(clientApp, sendPath, phone, scene) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
};
|
||||
const res = await fetch(`${API}${sendPath}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ phone, scene }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendSmsWithCooldown(clientApp, sendPath, phone, scene) {
|
||||
let json = await sendSmsOnce(clientApp, sendPath, phone, scene);
|
||||
if (json.code !== 0 && String(json.message).includes('过于频繁')) {
|
||||
await sleep(65_000);
|
||||
json = await sendSmsOnce(clientApp, sendPath, phone, scene);
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const existing = readSmsCodeFromRedis(phone, scene);
|
||||
if (existing) return { reusedCode: true };
|
||||
throw new Error(`${sendPath}: ${json.message}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function loginWithSms(clientApp, phone, scene, loginPath, sendPath) {
|
||||
await sendSmsWithCooldown(clientApp, sendPath, phone, scene);
|
||||
const code = await resolveSmsCode(phone, scene);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
};
|
||||
const res = await fetch(`${API}${loginPath}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${loginPath}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 合伙人登录 / 手机号校验专项冒烟(需 API 已启动)
|
||||
* 用法: node scripts/test-partner-auth.mjs
|
||||
*/
|
||||
import { loginWithSms } from './sms-test-helper.mjs';
|
||||
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
|
||||
async function req(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function expectFail(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code === 0) throw new Error(`${path}: expected failure`);
|
||||
return json.message;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('1. PARTNER phone/check unknown');
|
||||
const unknownCheck = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999' }),
|
||||
});
|
||||
if (!unknownCheck.includes('未找到合伙人账号')) throw new Error(`unexpected: ${unknownCheck}`);
|
||||
|
||||
console.log('2. PARTNER sms/send unknown');
|
||||
const unknownSms = await expectFail('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999', scene: 'PARTNER_LOGIN' }),
|
||||
});
|
||||
if (!unknownSms.includes('未找到合伙人账号')) throw new Error(`unexpected: ${unknownSms}`);
|
||||
|
||||
console.log('3. PARTNER phone/check + login bound phone');
|
||||
const check = await req('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13700000001' }),
|
||||
});
|
||||
if (!check.ok || !check.maskedPhone) throw new Error('phone check failed');
|
||||
|
||||
const login = await loginWithSms(
|
||||
'PARTNER_H5',
|
||||
'13700000001',
|
||||
'PARTNER_LOGIN',
|
||||
'/partner/auth/login/sms',
|
||||
'/partner/auth/sms/send',
|
||||
);
|
||||
if (!login.accessToken) throw new Error('login missing token');
|
||||
|
||||
console.log('4. Admin partner logs list');
|
||||
const admin = await req('HQ_WEB', '/admin/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
loginName: process.env.SUPER_ADMIN_LOGIN ?? 'admin',
|
||||
password: process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!',
|
||||
}),
|
||||
});
|
||||
const logs = await req('HQ_WEB', '/admin/logs/partners?page=1&pageSize=10&category=login', {
|
||||
token: admin.accessToken,
|
||||
});
|
||||
if (!Array.isArray(logs.items)) throw new Error('partner logs missing items');
|
||||
const hasLogin = logs.items.some((r) => r.eventName === 'partner_sms_login' || r.eventName === 'partner_login_success');
|
||||
if (!hasLogin) throw new Error('partner login log not found');
|
||||
|
||||
console.log('\n✅ partner-auth tests passed');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -889,3 +889,20 @@ model LogStoreAnalytics {
|
||||
@@index([eventName, createdAt])
|
||||
@@map("log_store_analytics")
|
||||
}
|
||||
|
||||
model LogPartnerAnalytics {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
eventName String @map("event_name") @db.VarChar(64)
|
||||
clientApp ClientApp? @map("client_app")
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([partnerId, createdAt])
|
||||
@@index([partnerAccountId, createdAt])
|
||||
@@index([eventName, createdAt])
|
||||
@@map("log_partner_analytics")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ export type TrackStoreEventInput = TrackEventInput & {
|
||||
storeId: bigint;
|
||||
};
|
||||
|
||||
export type TrackPartnerEventInput = TrackEventInput & {
|
||||
partnerAccountId?: bigint;
|
||||
partnerId: bigint;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -58,6 +63,24 @@ export class AnalyticsService {
|
||||
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
async trackPartnerOne(
|
||||
partnerAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
await this.prisma.logPartnerAnalytics.create({
|
||||
data: this.toPartnerRow(partnerAccountId, clientApp, event),
|
||||
});
|
||||
}
|
||||
|
||||
trackPartnerOneSafe(
|
||||
partnerAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
|
||||
return {
|
||||
userId,
|
||||
@@ -85,6 +108,23 @@ export class AnalyticsService {
|
||||
extraJson: event.extraJson as never,
|
||||
};
|
||||
}
|
||||
|
||||
private toPartnerRow(
|
||||
partnerAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
return {
|
||||
partnerAccountId,
|
||||
partnerId: event.partnerId,
|
||||
eventName: event.eventName,
|
||||
clientApp: clientApp as ClientApp,
|
||||
refType: event.refType,
|
||||
refId: event.refId,
|
||||
extraJson: event.extraJson as never,
|
||||
};
|
||||
}
|
||||
|
||||
/** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */
|
||||
async touchPromo(promoCode: string, userId?: bigint) {
|
||||
const code = promoCode.trim().toUpperCase();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BindWechatDto,
|
||||
BindWechatPhoneDto,
|
||||
BootstrapSessionDto,
|
||||
CheckPartnerPhoneDto,
|
||||
LoginSmsDto,
|
||||
LoginWechatDto,
|
||||
RefreshTokenDto,
|
||||
@@ -142,6 +143,11 @@ export class ShopAuthController {
|
||||
export class PartnerAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('phone/check')
|
||||
checkPhone(@Body() dto: CheckPartnerPhoneDto) {
|
||||
return this.authService.checkPartnerPhone(dto.phone);
|
||||
}
|
||||
|
||||
@Post('sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.PARTNER_H5 });
|
||||
|
||||
@@ -167,6 +167,44 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
private trackPartnerEvent(
|
||||
partnerAccountId: bigint | undefined,
|
||||
partnerId: bigint,
|
||||
clientApp: ClientApp | string,
|
||||
eventName: string,
|
||||
extraJson?: Record<string, unknown>,
|
||||
ref?: { refType?: string; refId?: bigint },
|
||||
) {
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, clientApp, {
|
||||
partnerId,
|
||||
eventName,
|
||||
refType: ref?.refType,
|
||||
refId: ref?.refId,
|
||||
extraJson,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertPartnerAccountByPhone(phone: string) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { phone },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
||||
return account;
|
||||
}
|
||||
|
||||
async checkPartnerPhone(phone: string) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const account = await this.assertPartnerAccountByPhone(normalizedPhone);
|
||||
return {
|
||||
ok: true,
|
||||
maskedPhone: this.maskPhone(normalizedPhone),
|
||||
name: account.name,
|
||||
companyName: account.partner.companyName,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertSmsSendAllowed(phone: string, scene: SmsScene) {
|
||||
if (scene === SmsScene.STORE_LOGIN) {
|
||||
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||
@@ -177,6 +215,10 @@ export class AuthService {
|
||||
if (scene === SmsScene.STORE_ACCOUNT_OPEN) {
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已绑定门店');
|
||||
return;
|
||||
}
|
||||
if (scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) {
|
||||
await this.assertPartnerAccountByPhone(phone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +294,28 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
(scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) &&
|
||||
actorRef?.refType === 'PARTNER'
|
||||
) {
|
||||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: actorRef.refId },
|
||||
select: { id: true, partnerId: true },
|
||||
});
|
||||
if (partnerAccount) {
|
||||
this.trackPartnerEvent(
|
||||
partnerAccount.id,
|
||||
partnerAccount.partnerId,
|
||||
clientApp,
|
||||
'partner_sms_send',
|
||||
{
|
||||
scene,
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
status: 'success',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException) throw err;
|
||||
const message = err instanceof Error ? err.message : '短信发送失败';
|
||||
@@ -504,16 +568,34 @@ export class AuthService {
|
||||
|
||||
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
|
||||
try {
|
||||
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
|
||||
} catch (err) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
|
||||
if (account) {
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_verify_fail', {
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('合伙人账号不存在');
|
||||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_login', {
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
});
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
|
||||
method: 'sms',
|
||||
});
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
@@ -1012,6 +1094,8 @@ export class AuthService {
|
||||
include: { partner: true },
|
||||
});
|
||||
|
||||
this.trackPartnerEvent(updated.id, updated.partnerId, clientApp, 'partner_wechat_bind', { platform });
|
||||
|
||||
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, {
|
||||
id: updated.id.toString(),
|
||||
partnerId: updated.partnerId.toString(),
|
||||
@@ -1057,6 +1141,11 @@ export class AuthService {
|
||||
include: { partner: true },
|
||||
});
|
||||
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_wechat_login', { platform });
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
|
||||
method: 'wechat',
|
||||
});
|
||||
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
|
||||
@@ -93,3 +93,9 @@ export class BindWechatDto {
|
||||
@IsOptional()
|
||||
platform?: 'h5' | 'mini';
|
||||
}
|
||||
|
||||
export class CheckPartnerPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminPartnerLogsService } from './admin-partner-logs.service';
|
||||
import { AdminPartnerLogsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/logs/partners')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminPartnerLogsController {
|
||||
constructor(private readonly service: AdminPartnerLogsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminPartnerLogsQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
eventNamesForPartnerLogCategory,
|
||||
resolvePartnerLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminPartnerLogsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPartnerLogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AdminPartnerLogsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const partnerIds = await this.resolvePartnerIds(query);
|
||||
if (partnerIds && partnerIds.length === 0) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
|
||||
const where = this.buildWhere(query, partnerIds);
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.logPartnerAnalytics.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.logPartnerAnalytics.count({ where }),
|
||||
]);
|
||||
|
||||
const items = await this.enrichRows(rows);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: string) {
|
||||
const row = await this.prisma.logPartnerAnalytics.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!row) throw new NotFoundException('日志不存在');
|
||||
const [item] = await this.enrichRows([row]);
|
||||
return serializeBigInt(item);
|
||||
}
|
||||
|
||||
private buildWhere(
|
||||
query: AdminPartnerLogsQueryDto,
|
||||
partnerIds?: bigint[],
|
||||
): Prisma.LogPartnerAnalyticsWhereInput {
|
||||
const where: Prisma.LogPartnerAnalyticsWhereInput = {};
|
||||
if (partnerIds) where.partnerId = { in: partnerIds };
|
||||
if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
|
||||
const categoryEvents = query.eventName
|
||||
? [query.eventName]
|
||||
: query.category
|
||||
? eventNamesForPartnerLogCategory(query.category)
|
||||
: undefined;
|
||||
if (categoryEvents?.length) where.eventName = { in: categoryEvents };
|
||||
if (query.from || query.to) {
|
||||
where.createdAt = {
|
||||
...(query.from ? { gte: new Date(query.from) } : {}),
|
||||
...(query.to ? { lte: new Date(query.to) } : {}),
|
||||
};
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
private async resolvePartnerIds(query: AdminPartnerLogsQueryDto): Promise<bigint[] | undefined> {
|
||||
if (query.partnerId) return [BigInt(query.partnerId)];
|
||||
|
||||
const partnerWhere: Prisma.PartnerWhereInput = {};
|
||||
if (query.companyName) partnerWhere.companyName = { contains: query.companyName };
|
||||
|
||||
if (query.partnerAccountId || query.phone) {
|
||||
const accountWhere: Prisma.PartnerAccountWhereInput = {};
|
||||
if (query.partnerAccountId) accountWhere.id = BigInt(query.partnerAccountId);
|
||||
if (query.phone) accountWhere.phone = { contains: query.phone };
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where: accountWhere,
|
||||
select: { partnerId: true },
|
||||
take: 100,
|
||||
});
|
||||
if (accounts.length === 0) return [];
|
||||
const ids = [...new Set(accounts.map((a) => a.partnerId))];
|
||||
if (query.companyName) {
|
||||
const partners = await this.prisma.partner.findMany({
|
||||
where: { id: { in: ids }, ...partnerWhere },
|
||||
select: { id: true },
|
||||
});
|
||||
return partners.map((p) => p.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (query.companyName) {
|
||||
const partners = await this.prisma.partner.findMany({
|
||||
where: partnerWhere,
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
return partners.map((p) => p.id);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async enrichRows(
|
||||
rows: Array<{
|
||||
id: bigint;
|
||||
partnerAccountId: bigint | null;
|
||||
partnerId: bigint;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: bigint | null;
|
||||
extraJson: unknown;
|
||||
createdAt: Date;
|
||||
}>,
|
||||
) {
|
||||
const partnerIds = [...new Set(rows.map((r) => r.partnerId))];
|
||||
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId).filter((id): id is bigint => id != null))];
|
||||
|
||||
const [partners, accounts] = await Promise.all([
|
||||
partnerIds.length
|
||||
? this.prisma.partner.findMany({
|
||||
where: { id: { in: partnerIds } },
|
||||
select: { id: true, companyName: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
accountIds.length
|
||||
? this.prisma.partnerAccount.findMany({
|
||||
where: { id: { in: accountIds } },
|
||||
select: { id: true, name: true, phone: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const partnerMap = new Map(partners.map((p) => [p.id.toString(), p] as const));
|
||||
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
|
||||
|
||||
return rows.map((row) => {
|
||||
const partner = partnerMap.get(row.partnerId.toString());
|
||||
const account = row.partnerAccountId ? accountMap.get(row.partnerAccountId.toString()) : undefined;
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
partnerId: row.partnerId.toString(),
|
||||
partnerAccountId: row.partnerAccountId?.toString() ?? null,
|
||||
accountName: account?.name ?? null,
|
||||
accountPhone: account?.phone ?? null,
|
||||
companyName: partner?.companyName ?? null,
|
||||
category: resolvePartnerLogCategory(row.eventName),
|
||||
eventName: row.eventName,
|
||||
clientApp: row.clientApp,
|
||||
refType: row.refType,
|
||||
refId: row.refId?.toString() ?? null,
|
||||
extraJson: (row.extraJson as Record<string, unknown> | null) ?? null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,40 @@ export class AdminStoreLogsQueryDto extends PaginationQueryDto {
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class AdminPartnerLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerAccountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export class AdminHqLogsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -25,6 +25,8 @@ import { AdminUserLogsController } from './admin-user-logs.controller';
|
||||
import { AdminUserLogsService } from './admin-user-logs.service';
|
||||
import { AdminStoreLogsController } from './admin-store-logs.controller';
|
||||
import { AdminStoreLogsService } from './admin-store-logs.service';
|
||||
import { AdminPartnerLogsController } from './admin-partner-logs.controller';
|
||||
import { AdminPartnerLogsService } from './admin-partner-logs.service';
|
||||
import { AdminHqLogsController } from './admin-hq-logs.controller';
|
||||
import { AdminHqLogsService } from './admin-hq-logs.service';
|
||||
import { AdminTicketsController } from './admin-tickets.controller';
|
||||
@@ -63,6 +65,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||
AdminProductsController,
|
||||
AdminUserLogsController,
|
||||
AdminStoreLogsController,
|
||||
AdminPartnerLogsController,
|
||||
AdminHqLogsController,
|
||||
AdminTicketsController,
|
||||
AdminXiaofeixiaController,
|
||||
@@ -84,6 +87,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||
AdminProductsService,
|
||||
AdminUserLogsService,
|
||||
AdminStoreLogsService,
|
||||
AdminPartnerLogsService,
|
||||
AdminHqLogsService,
|
||||
AdminTicketsService,
|
||||
AdminXiaofeixiaService,
|
||||
|
||||
@@ -160,6 +160,11 @@ export class SettlementService {
|
||||
where: { partnerId: account.partnerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
eventName: 'partner_bill_view',
|
||||
extraJson: { count: bills.length },
|
||||
});
|
||||
return serializeBigInt(bills);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,14 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
eventName: 'partner_store_create',
|
||||
refType: 'STORE',
|
||||
refId: store.id,
|
||||
extraJson: { storeName: store.name, phone: normalizedPhone },
|
||||
});
|
||||
|
||||
return serializeBigInt({ store, audit });
|
||||
}
|
||||
|
||||
@@ -217,14 +225,14 @@ export class StoreService {
|
||||
data: { status },
|
||||
include: { coverResource: true },
|
||||
});
|
||||
this.analyticsService.trackStoreOneSafe(undefined, 'PARTNER_H5', {
|
||||
storeId,
|
||||
eventName: 'store_status_change',
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
eventName: 'partner_store_status_change',
|
||||
refType: 'STORE',
|
||||
refId: storeId,
|
||||
extraJson: {
|
||||
status,
|
||||
previousStatus: store.status,
|
||||
actor: 'PARTNER',
|
||||
partnerAccountId: partnerAccountId.toString(),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat(updated));
|
||||
|
||||
@@ -522,6 +522,25 @@ export class TradeService {
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
await this.applyStatusTransition(order.id, order.status, targetStatus);
|
||||
if (targetStatus === 'SHIPPING') {
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
eventName: 'partner_order_ship',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
extraJson: { fromStatus: order.status },
|
||||
});
|
||||
}
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
eventName: 'partner_delivery_advance',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
extraJson: {
|
||||
fromStatus: order.status,
|
||||
targetStatus,
|
||||
},
|
||||
});
|
||||
return this.getPartnerOrder(partnerAccountId, orderId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user