代下单功能

This commit is contained in:
2026-07-12 11:40:04 +08:00
parent d949301bc1
commit de01d36cdb
46 changed files with 2657 additions and 253 deletions
+9
View File
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import StoreMediaPage from './pages/StoreMediaPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
@@ -52,6 +56,11 @@ export default function App() {
<Route path="/users" element={<UsersPage />} />
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/promo-codes" element={<PromoCodesPage />} />
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
<Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} />
</Route>
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
+4 -1
View File
@@ -35,6 +35,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{
key: 'stores-group',
icon: <ShopOutlined />,
@@ -118,7 +119,9 @@ export default function AdminLayout() {
navigate('/login');
}
const selectedKey = location.pathname;
const selectedKey = location.pathname.startsWith('/promo-codes')
? '/promo-codes'
: location.pathname;
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
+3
View File
@@ -83,6 +83,9 @@ export type AdminUserRow = {
wechatVerified: boolean;
nickname: string | null;
status: number;
sourceType: string;
sourceRefId: string | null;
sourceLabel: string | null;
createdAt: string;
orderCount: number;
};
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = PromoCodeItem;
type SceneOption = { value: PromoCodeScene; label: string };
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodesPage() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button>
<Button
+69 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
Alert,
Button,
@@ -17,6 +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 { request, type AdminUserRow, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
@@ -33,6 +34,7 @@ type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null;
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
sourcePromo?: { id: string; code: string; name: string } | null;
orders?: UserOrderRow[];
mergedFromCount?: number;
addressCount?: number;
@@ -62,6 +64,7 @@ type BatchDeletePreview = {
export default function UsersPage() {
const navigate = useNavigate();
const location = useLocation();
const [form] = Form.useForm();
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
@@ -105,6 +108,14 @@ export default function UsersPage() {
void load();
}, [load]);
useEffect(() => {
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
if (openUserId) {
void openDetail(openUserId);
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state, location.pathname, navigate]);
async function openDetail(id: string) {
const res = await request<UserDetail>(`/admin/users/${id}`);
setDetail(res);
@@ -225,6 +236,40 @@ export default function UsersPage() {
width: 100,
render: (v) => (v ? <Tag color="blue"></Tag> : <Tag></Tag>),
},
{
title: '来源类型',
dataIndex: 'sourceType',
width: 100,
render: (v: string) => (
<Tag color={v === 'PROMO_CODE' ? 'blue' : v === 'ORGANIC' ? 'default' : 'purple'}>
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
</Tag>
),
},
{
title: '来源 ID',
dataIndex: 'sourceRefId',
width: 100,
ellipsis: true,
render: (v, row) => {
if (!v) return '—';
if (row.sourceType === 'PROMO_CODE') {
return (
<Link to={`/promo-codes/${v}`} onClick={(e) => e.stopPropagation()}>
{v}
</Link>
);
}
return v;
},
},
{
title: '来源标签',
dataIndex: 'sourceLabel',
width: 120,
ellipsis: true,
render: (v) => v || '—',
},
{
title: 'deviceKey',
dataIndex: 'deviceKey',
@@ -307,7 +352,7 @@ export default function UsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1500 }}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
@@ -347,6 +392,28 @@ export default function UsersPage() {
<Descriptions.Item label="微信验证">
{detail.wechatVerified ? <Tag color="blue"></Tag> : <Tag></Tag>}
</Descriptions.Item>
<Descriptions.Item label="来源类型">
<Tag color={detail.sourceType === 'PROMO_CODE' ? 'blue' : 'default'}>
{USER_SOURCE_TYPE_LABELS[detail.sourceType as UserSourceType] || detail.sourceType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="来源 ID">
{detail.sourceRefId ? (
detail.sourceType === 'PROMO_CODE' ? (
<Link to={`/promo-codes/${detail.sourceRefId}`}>{detail.sourceRefId}</Link>
) : (
detail.sourceRefId
)
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
{detail.sourcePromo && (
<Descriptions.Item label="推广码">
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>
{detail.sourcePromo.name}{detail.sourcePromo.code}
</Link>
</Descriptions.Item>
)}
<Descriptions.Item label="wxOpenId">{detail.wxOpenId || '—'}</Descriptions.Item>
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Breadcrumb, Button, Space, Spin, Tabs, Tag, Typography } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import {
PROMO_CODE_STATUS_LABELS,
type PromoCodeItem,
type PromoCodeStats,
} from '@dukang/shared-types';
import { request } from '../../lib/api';
export type PromoCodeDetailContext = {
detail: PromoCodeItem & { stats?: PromoCodeStats };
reload: () => Promise<void>;
};
export default function PromoCodeDetailLayout() {
const { id = '' } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [detail, setDetail] = useState<(PromoCodeItem & { stats?: PromoCodeStats }) | null>(null);
const [loading, setLoading] = useState(true);
async function loadDetail() {
setLoading(true);
try {
const res = await request<PromoCodeItem & { stats?: PromoCodeStats }>(`/admin/promo-codes/${id}`);
setDetail(res);
} catch {
setDetail(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadDetail();
}, [id]);
const tabKey = location.pathname.endsWith('/users') ? 'users' : 'overview';
if (loading) {
return <Spin style={{ display: 'block', margin: '80px auto' }} />;
}
if (!detail) {
return (
<div>
<Typography.Text type="danger">广</Typography.Text>
<div style={{ marginTop: 16 }}>
<Button onClick={() => navigate('/promo-codes')}></Button>
</div>
</div>
);
}
return (
<div>
<Breadcrumb
style={{ marginBottom: 12 }}
items={[
{ title: <Link to="/promo-codes">广</Link> },
{ title: detail.name },
]}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<Space direction="vertical" size={4}>
<Space align="center">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/promo-codes')}
style={{ marginLeft: -8 }}
/>
<Typography.Title level={4} style={{ margin: 0 }}>
{detail.name}
</Typography.Title>
<Tag color={detail.status === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Space>
<Typography.Text type="secondary" copyable={{ text: detail.code }}>
{detail.code}
</Typography.Text>
</Space>
</div>
<Tabs
activeKey={tabKey}
onChange={(key) => {
if (key === 'users') navigate(`/promo-codes/${id}/users`);
else navigate(`/promo-codes/${id}`);
}}
items={[
{ key: 'overview', label: '概览' },
{
key: 'users',
label: `关联用户${detail.stats?.sourceMarkedCount != null ? ` (${detail.stats.sourceMarkedCount})` : ''}`,
},
]}
style={{ marginBottom: 16 }}
/>
<Outlet context={{ detail, reload: loadDetail } satisfies PromoCodeDetailContext} />
</div>
);
}
@@ -0,0 +1,226 @@
import { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Typography,
message,
} from 'antd';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
} from '@dukang/shared-types';
import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodeDetailPage() {
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false);
const stats = detail.stats;
async function handleEdit(values: Record<string, string>) {
setSaving(true);
try {
await request(`/admin/promo-codes/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || null,
remark: values.remark?.trim() || null,
}),
});
message.success('已保存');
setEditOpen(false);
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<>
<Row gutter={[16, 16]}>
<Col xs={24} lg={8}>
<Card title="推广二维码" size="small">
{detail.qrcodeUrl ? (
<div style={{ textAlign: 'center' }}>
<img
src={detail.qrcodeUrl}
alt="推广二维码"
style={{ width: 200, height: 200, marginBottom: 12 }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
>
</Button>
</Space>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</Col>
<Col xs={24} lg={16}>
<Card
title="基础信息"
size="small"
extra={(
<Space>
<Button size="small" onClick={() => {
editForm.setFieldsValue({
name: detail.name,
scene: detail.scene,
remark: detail.remark,
ownerUserId: detail.ownerUser?.id,
});
setEditOpen(true);
}}
>
</Button>
<Popconfirm
title={detail.status === 'ACTIVE' ? '确认关闭此推广码?关闭后不可再归因' : '确认重新启用?'}
onConfirm={async () => {
await request(`/admin/promo-codes/${detail.id}/status`, {
method: 'PUT',
body: JSON.stringify({
status: detail.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE',
}),
});
message.success('状态已更新');
await reload();
}}
>
<Button size="small" danger={detail.status === 'ACTIVE'}>
{detail.status === 'ACTIVE' ? '关闭' : '启用'}
</Button>
</Popconfirm>
</Space>
)}
>
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
<Descriptions.Item label="场景">
{PROMO_CODE_SCENE_LABELS[detail.scene] || detail.scene}
</Descriptions.Item>
<Descriptions.Item label="状态">
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="二维码 ID" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="落地链接" span={2}>
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="OSS 地址" span={2}>
{detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
{detail.qrcodeUrl}
</Typography.Text>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="渠道负责人">
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="转化率"
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row>
<Modal
title="编辑推广码"
open={editOpen}
onCancel={() => setEditOpen(false)}
footer={null}
destroyOnClose
>
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
<Select
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID">
<Input placeholder="留空表示解除关联" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={saving} block>
</Button>
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,89 @@
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { Button, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
USER_SOURCE_TYPE_LABELS,
type PromoCodeAttributedUser,
type UserSourceType,
} from '@dukang/shared-types';
import { fmtTime } from '../../lib/constants';
import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
export default function PromoCodeUsersPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const { detail } = useOutletContext<PromoCodeDetailContext>();
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<PromoCodeAttributedUser>(
`/admin/promo-codes/${id}/users`,
() => new URLSearchParams(),
[id],
);
const columns: ColumnsType<PromoCodeAttributedUser> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{
title: '验手机',
dataIndex: 'phoneVerifiedAt',
width: 90,
render: (v) => (v ? <Tag color="green"></Tag> : <Tag color="orange">访</Tag>),
},
{
title: '来源类型',
dataIndex: 'sourceType',
width: 100,
render: (v: string) => (
<Tag color={v === 'PROMO_CODE' ? 'blue' : 'default'}>
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
</Tag>
),
},
{
title: '来源 ID',
dataIndex: 'sourceRefId',
width: 100,
render: (v) => (v === detail.id ? <Tag color="blue"></Tag> : v || '—'),
},
{
title: '首次触达',
dataIndex: 'firstTouchAt',
width: 160,
render: (v) => (v ? fmtTime(v) : '—'),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 100,
fixed: 'right',
render: (_, row) => (
<Space size="small">
<Button type="link" size="small" onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
</Button>
</Space>
),
},
];
return (
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
);
}
+2 -1
View File
@@ -14,7 +14,7 @@ import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import LeaderboardPage from './pages/LeaderboardPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage';
@@ -32,6 +32,7 @@ function PrimaryRoutes() {
<Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
+6
View File
@@ -100,6 +100,12 @@ export default function HomePage() {
<section className="partner-bento-card">
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
@@ -0,0 +1,330 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast';
import type {
PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
} from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderPage() {
const navigate = useNavigate();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState('');
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
const regionLabel = region ? formatRegionLabel(region) : '';
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
})
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, []);
useEffect(() => {
if (!productId || quantity < 1) {
setPreview(null);
return;
}
const timer = setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('PARTNER_H5', '/partner/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
receiverCity: region.city || undefined,
receiverDistrict: region.district || undefined,
}),
silent: true,
})
.then(setPreview)
.catch(() => setPreview(null))
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, region.city, region.district]);
async function sendSms() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
setMsg(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function submit() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
if (!smsCode.trim()) {
setMsg('请输入验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
return;
}
const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(),
smsCode: smsCode.trim(),
receiverName: receiverName.trim() || undefined,
province: region.province,
city: region.city,
district: region.district,
addressDetail: addressDetail.trim(),
productId,
quantity,
promoCodeId: promoCodeId || undefined,
};
setSubmitting(true);
try {
const order = await request<{ id: string; orderNo: string }>('PARTNER_H5', '/partner/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
silent: true,
});
toastSuccess(`代下单成功:${order.orderNo}`);
navigate(`/orders/${order.id}`);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setSubmitting(false);
}
}
const selectedProduct = options?.products.find((p) => p.id === productId);
return (
<div className="page partner-proxy-order-page">
<PageHeader title="代下单" onBack={() => navigate(-1)} />
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{loadingOptions ? (
<p className="label-md text-muted">广</p>
) : (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="11 位手机号"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={smsCooldown > 0}
onClick={() => void sendSms()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="线下代发货确认码"
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<select
className="partner-input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
>
{(options?.products ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.spec}¥{fmtMoney(p.price)}
</option>
))}
</select>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
/>
</div>
</section>
{(options?.promoCodes.length ?? 0) > 0 && (
<section className="partner-form-section">
<label className="partner-form-label">广</label>
<select
className="partner-input"
value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
>
<option value=""></option>
{options!.promoCodes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.code}
</option>
))}
</select>
</section>
)}
<section className="partner-proxy-fee-card">
<h3 className="headline-md"></h3>
{previewLoading ? (
<p className="label-md text-muted"></p>
) : preview ? (
<>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">¥{fmtMoney(preview.unitPrice)}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">×{quantity}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">¥{fmtMoney(preview.benefitAmount)}</span>
</div>
<div className="partner-proxy-fee-row partner-proxy-fee-row--total">
<span className="headline-md"></span>
<span className="amount-lg text-primary">¥{fmtMoney(preview.payAmount)}</span>
</div>
</>
) : (
<p className="label-md text-muted">
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
</p>
)}
</section>
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
<button
type="button"
className="btn btn-primary btn-block"
disabled={submitting || !preview}
onClick={() => void submit()}
style={{ marginTop: 16 }}
>
{submitting ? '提交中…' : '验证码确认并代下单'}
</button>
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
/线
</p>
</>
)}
</main>
</div>
);
}
+25
View File
@@ -3031,3 +3031,28 @@ body {
color: var(--color-outline, #8d706e);
pointer-events: none;
}
.partner-form-section {
margin-bottom: 16px;
}
.partner-proxy-fee-card {
margin-top: 8px;
padding: 16px;
border-radius: 16px;
background: rgba(166, 29, 36, 0.04);
border: 1px solid rgba(166, 29, 36, 0.08);
}
.partner-proxy-fee-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
}
.partner-proxy-fee-row--total {
margin-top: 8px;
padding-top: 12px;
border-top: 1px dashed rgba(166, 29, 36, 0.15);
}
+1 -2
View File
@@ -20,13 +20,12 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage';
import CustomerServicePage from './pages/CustomerServicePage';
import { UserSessionProvider } from './contexts/UserSessionContext';
import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
import { capturePromoFromUrl } from './lib/promo';
import WechatShareBootstrap from './components/WechatShareBootstrap';
function PromoBootstrap() {
useEffect(() => {
capturePromoFromUrl();
void touchPromoIfNeeded();
}, []);
return null;
}
+28 -11
View File
@@ -1,25 +1,33 @@
import { apiBase } from './api';
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid';
function readPromoFromSearch(search: string): string | null {
function readPromoFromSearch(search: string): { code: string | null; pid: string | null } {
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
const code = params.get('promo')?.trim();
return code ? code.toUpperCase() : null;
const pid = params.get('pid')?.trim();
return {
code: code ? code.toUpperCase() : null,
pid: pid || null,
};
}
/** 解析 URL 中的 ?promo= 并写入 sessionStorage */
/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */
export function capturePromoFromUrl(): string | null {
if (typeof window === 'undefined') return null;
let code = readPromoFromSearch(window.location.search);
if (!code && window.location.hash.includes('?')) {
let parsed = readPromoFromSearch(window.location.search);
if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
code = readPromoFromSearch(hashQuery);
parsed = readPromoFromSearch(hashQuery);
}
if (code) {
sessionStorage.setItem(PROMO_STORAGE_KEY, code);
if (parsed.code) {
sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code);
}
return code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
if (parsed.pid) {
sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid);
}
return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
}
export function getStoredPromoCode(): string | null {
@@ -27,10 +35,16 @@ export function getStoredPromoCode(): string | null {
return sessionStorage.getItem(PROMO_STORAGE_KEY);
}
export function getStoredPromoPid(): string | null {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem(PROMO_PID_STORAGE_KEY);
}
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count */
export async function touchPromoIfNeeded(): Promise<void> {
const promoCode = getStoredPromoCode();
if (!promoCode) return;
const qrcodeId = getStoredPromoPid();
if (!promoCode && !qrcodeId) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -43,7 +57,10 @@ export async function touchPromoIfNeeded(): Promise<void> {
const res = await fetch(`${apiBase}/promo/touch`, {
method: 'POST',
headers,
body: JSON.stringify({ promoCode }),
body: JSON.stringify({
...(promoCode ? { promoCode } : {}),
...(qrcodeId ? { qrcodeId } : {}),
}),
});
const json = await res.json();
if (json.code !== 0) return;
+9
View File
@@ -18,12 +18,19 @@ export interface AppConfig {
aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 aliyunSmsTemplateCode */
aliyunSmsRedeemConfirmTemplateCode: string;
/** 合伙人代下单短信模板(PARTNER_PROXY_ORDER / 线下代发货);未配置时回退 aliyunSmsTemplateCode */
aliyunSmsProxyOrderTemplateCode: string;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
/** 腾讯位置服务 Key(逆地理编码) */
tencentLbsKey: string;
/** C 端 H5 落地页(推广码二维码链接前缀) */
userH5Url: string;
}
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
@@ -48,8 +55,10 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
aliyunSmsRedeemConfirmTemplateCode: e.ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE ?? '',
aliyunSmsProxyOrderTemplateCode: e.ALIYUN_SMS_PROXY_ORDER_TEMPLATE_CODE ?? '',
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
};
}
+33
View File
@@ -15,6 +15,25 @@ export enum ActorType {
HQ = 'HQ',
}
/** 用户注册/首次归因来源(对应 user_user.source_type */
export enum UserSourceType {
ORGANIC = 'ORGANIC',
PROMO_CODE = 'PROMO_CODE',
SHARE_LINK = 'SHARE_LINK',
FRIEND_REFERRAL = 'FRIEND_REFERRAL',
OFFLINE_EVENT = 'OFFLINE_EVENT',
OTHER = 'OTHER',
}
export const USER_SOURCE_TYPE_LABELS: Record<UserSourceType, string> = {
[UserSourceType.ORGANIC]: '自然流量',
[UserSourceType.PROMO_CODE]: '推广码',
[UserSourceType.SHARE_LINK]: '分享链接',
[UserSourceType.FRIEND_REFERRAL]: '好友推荐',
[UserSourceType.OFFLINE_EVENT]: '线下活动',
[UserSourceType.OTHER]: '其他',
};
export enum SmsScene {
USER_LOGIN = 'USER_LOGIN',
STORE_LOGIN = 'STORE_LOGIN',
@@ -27,8 +46,22 @@ export enum SmsScene {
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
/** 合伙人代下单:线下代发货确认验证码(发至用户手机) */
PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER',
}
export enum OrderType {
NORMAL = 'NORMAL',
RESHIPMENT = 'RESHIPMENT',
PROXY = 'PROXY',
}
export const ORDER_TYPE_LABELS: Record<OrderType, string> = {
[OrderType.NORMAL]: '普通订单',
[OrderType.RESHIPMENT]: '补发订单',
[OrderType.PROXY]: '线下代下单',
};
export enum OrderStatus {
PENDING_PAY = 'PENDING_PAY',
PENDING_SHIP = 'PENDING_SHIP',
+63
View File
@@ -1,24 +1,87 @@
export type PromoCodeStatus = 'ACTIVE' | 'DISABLED';
export type PromoCodeScene =
| 'ONLINE_LINK'
| 'OFFLINE_PICKUP'
| 'PARTNER_CHANNEL'
| 'EVENT'
| 'OTHER';
export const PROMO_CODE_SCENE_LABELS: Record<PromoCodeScene, string> = {
ONLINE_LINK: '线上链接',
OFFLINE_PICKUP: '现场提货',
PARTNER_CHANNEL: '合伙人渠道',
EVENT: '活动品鉴',
OTHER: '其他',
};
export const PROMO_CODE_STATUS_LABELS: Record<PromoCodeStatus, string> = {
ACTIVE: '启用',
DISABLED: '已关闭',
};
export type PromoCodeOwnerUser = {
id: string;
userNo?: string | null;
nickname?: string | null;
phone?: string | null;
};
export type PromoCodeItem = {
id: string;
code: string;
name: string;
scene: PromoCodeScene;
qrcodeId: string;
status: PromoCodeStatus;
scanCount: number;
orderCount: number;
landingUrl: string;
qrcodeUrl?: string | null;
remark?: string | null;
ownerUser?: PromoCodeOwnerUser | null;
createdAt: string;
updatedAt: string;
};
export type PromoCodeStats = {
scanCount: number;
orderCount: number;
conversionRate: number;
attributionCount?: number;
/** 用户表 source_ref_id 指向本推广码的用户数 */
sourceMarkedCount?: number;
};
export type PromoCodeAttributedUser = {
id: string;
userNo: string;
nickname: string | null;
phone: string | null;
phoneVerifiedAt: string | null;
sourceType: string;
sourceRefId: string | null;
firstTouchAt: string | null;
orderCount: number;
createdAt: string;
};
export type PromoTouchResult = {
promoCode: string;
promoCodeId: string;
channelName: string;
/** 是否首次写入 user_promo_attribution */
attributed: boolean;
/** 是否已将用户来源标记为 PROMO_CODE */
sourceApplied: boolean;
};
export function promoConversion(scan: number, orders: number): string {
if (scan <= 0) return '0%';
return `${Math.round((orders / scan) * 1000) / 10}%`;
}
export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
const base = baseUrl.replace(/\/$/, '');
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
}
+47
View File
@@ -33,3 +33,50 @@ export interface DeliveryDto {
shippingAt?: string;
deliveredAt?: string;
}
export type PartnerProxyOrderProductOption = {
id: string;
name: string;
spec: string;
price: number;
benefitAmount: number | null;
};
export type PartnerProxyOrderPromoOption = {
id: string;
code: string;
name: string;
};
export type PartnerProxyOrderOptions = {
products: PartnerProxyOrderProductOption[];
promoCodes: PartnerProxyOrderPromoOption[];
};
export type PartnerProxyOrderPreviewRequest = {
productId: string;
quantity: number;
receiverCity?: string;
receiverDistrict?: string;
};
export type PartnerProxyOrderPreviewResult = {
productAmount: number;
payAmount: number;
benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
unitPrice: number;
};
export type PartnerProxyOrderCreateRequest = {
phone: string;
smsCode: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
productId: string;
quantity: number;
promoCodeId?: string;
};
+13
View File
@@ -356,6 +356,9 @@ importers:
ip2region:
specifier: ^2.3.0
version: 2.3.0(@types/node@20.19.43)
qrcode:
specifier: ^1.5.4
version: 1.5.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -381,6 +384,9 @@ importers:
'@types/node':
specifier: ^20.14.0
version: 20.19.43
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
prisma:
specifier: ^5.18.0
version: 5.22.0
@@ -2492,6 +2498,9 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -8827,6 +8836,10 @@ snapshots:
'@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 20.19.43
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
+5 -3
View File
@@ -16,6 +16,8 @@ ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE=
# 合伙人代下单「线下代发货」短信模板(PARTNER_PROXY_ORDER);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_PROXY_ORDER_TEMPLATE_CODE=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
@@ -27,9 +29,9 @@ MOCK_WECHAT=true
# 登录后是否走微信 SDK OAuth 授权(本地 false 可仅用短信登录/核销,不影响支付 Mock)
WX_AUTHORIZE=false
# C 端 H5 落地页(推广码二维码链接前缀)
# 本地开发:http://localhost:5173/user 生产统一入口:https://user.runxian.top/user
USER_H5_URL=http://localhost:5173/user
# C 端 H5 落地页(推广码二维码链接前缀USER_H5_URL
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
# USER_H5_URL=https://user.runxian.top/user
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
+2
View File
@@ -36,6 +36,7 @@
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -46,6 +47,7 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
+17 -8
View File
@@ -136,16 +136,25 @@ CREATE TABLE common_store_category (
DROP TABLE IF EXISTS common_promo_code;
CREATE TABLE common_promo_code (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK',
qrcode_id VARCHAR(64) NOT NULL COMMENT '二维码唯一识别码',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
owner_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '关联用户(统计/归因)',
remark VARCHAR(256) DEFAULT NULL,
qrcode_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '小程序码 common_resource.id',
scan_count INT NOT NULL DEFAULT 0,
order_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
scan_count INT NOT NULL DEFAULT 0,
order_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_promo_code_code (code)
UNIQUE KEY uk_common_promo_code_code (code),
UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id),
KEY idx_common_promo_code_owner (owner_user_id),
KEY idx_common_promo_code_scene_status (scene, status),
CONSTRAINT fk_common_promo_code_owner_user FOREIGN KEY (owner_user_id) REFERENCES user_user(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER(城市合伙人主账号 + 子账号) =============================
@@ -0,0 +1,28 @@
-- 推广码表扩展:scene / qrcode_id / owner_user_id / remark / updated_at
-- 已有数据会先回填 qrcode_id 再设 NOT NULL
ALTER TABLE common_promo_code
ADD COLUMN IF NOT EXISTS scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name,
ADD COLUMN IF NOT EXISTS qrcode_id VARCHAR(64) NULL AFTER scene,
ADD COLUMN IF NOT EXISTS owner_user_id BIGINT UNSIGNED NULL AFTER status,
ADD COLUMN IF NOT EXISTS remark VARCHAR(256) NULL AFTER owner_user_id,
ADD COLUMN IF NOT EXISTS updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at;
UPDATE common_promo_code
SET qrcode_id = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
WHERE code = 'DKHQ001' AND (qrcode_id IS NULL OR qrcode_id = '');
UPDATE common_promo_code
SET qrcode_id = 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678'
WHERE code = 'DKDEMO1' AND (qrcode_id IS NULL OR qrcode_id = '');
UPDATE common_promo_code
SET qrcode_id = LOWER(REPLACE(UUID(), '-', ''))
WHERE qrcode_id IS NULL OR qrcode_id = '';
UPDATE common_promo_code
SET updated_at = created_at
WHERE updated_at IS NULL;
ALTER TABLE common_promo_code
MODIFY qrcode_id VARCHAR(64) NOT NULL;
@@ -0,0 +1,106 @@
/**
* 一次性迁移:common_promo_code 扩展字段(scene / qrcode_id / owner_user_id / remark / updated_at
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/migrate-promo-code-v31.ts
*/
import { PrismaClient } from '@prisma/client';
import { randomBytes } from 'crypto';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ Field: string }>>(
`SHOW COLUMNS FROM ${table} LIKE '${column}'`,
);
return rows.length > 0;
}
async function addColumn(sql: string) {
try {
await prisma.$executeRawUnsafe(sql);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate column')) throw e;
}
}
async function main() {
if (!(await columnExists('common_promo_code', 'scene'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name`,
);
}
if (!(await columnExists('common_promo_code', 'qrcode_id'))) {
await addColumn(`ALTER TABLE common_promo_code ADD COLUMN qrcode_id VARCHAR(64) NULL AFTER scene`);
}
if (!(await columnExists('common_promo_code', 'owner_user_id'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN owner_user_id BIGINT UNSIGNED NULL AFTER status`,
);
}
if (!(await columnExists('common_promo_code', 'remark'))) {
await addColumn(`ALTER TABLE common_promo_code ADD COLUMN remark VARCHAR(256) NULL AFTER owner_user_id`);
}
if (!(await columnExists('common_promo_code', 'updated_at'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at`,
);
}
const rows = await prisma.$queryRawUnsafe<Array<{ id: bigint; code: string; qrcode_id: string | null }>>(
`SELECT id, code, qrcode_id FROM common_promo_code`,
);
const preset: Record<string, string> = {
DKHQ001: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
DKDEMO1: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
};
for (const row of rows) {
if (row.qrcode_id) continue;
const qrcodeId = preset[row.code] ?? randomBytes(32).toString('hex');
await prisma.$executeRawUnsafe(
`UPDATE common_promo_code SET qrcode_id = ? WHERE id = ?`,
qrcodeId,
row.id,
);
}
await prisma.$executeRawUnsafe(`UPDATE common_promo_code SET updated_at = created_at WHERE updated_at IS NULL`);
await prisma.$executeRawUnsafe(`ALTER TABLE common_promo_code MODIFY qrcode_id VARCHAR(64) NOT NULL`);
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_owner (owner_user_id)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_scene_status (scene, status)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
console.log('migrate-promo-code-v31: OK');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+18
View File
@@ -100,6 +100,14 @@ enum PromoCodeStatus {
DISABLED
}
enum PromoCodeScene {
ONLINE_LINK
OFFLINE_PICKUP
PARTNER_CHANNEL
EVENT
OTHER
}
enum CityStatus {
PENDING
ACTIVE
@@ -168,6 +176,7 @@ enum UserSourceType {
enum OrderType {
NORMAL
RESHIPMENT
PROXY
}
enum OrderStatus {
@@ -392,16 +401,24 @@ model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
scene PromoCodeScene @default(ONLINE_LINK)
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
status PromoCodeStatus @default(ACTIVE)
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
scanCount Int @default(0) @map("scan_count")
orderCount Int @default(0) @map("order_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[]
orders Order[]
@@index([ownerUserId])
@@index([scene, status])
@@map("common_promo_code")
}
@@ -587,6 +604,7 @@ model User {
addresses UserAddress[]
cityPreference UserCityPreference?
promoTouch UserPromoAttribution?
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
orders Order[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
+8
View File
@@ -589,6 +589,10 @@ async function main() {
name: '总部品鉴会',
scene: 'EVENT',
qrcodeId: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
status: 'ACTIVE',
},
@@ -605,6 +609,10 @@ async function main() {
name: '郑州品鉴会演示',
scene: 'OFFLINE_PICKUP',
qrcodeId: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
status: 'ACTIVE',
},
+2
View File
@@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { PromoModule } from './modules/promo/promo.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
@@ -42,6 +43,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
AnalyticsModule,
JobsModule,
OpsModule,
PromoModule,
CityScopeModule,
CommonModule,
HqOperationModule,
@@ -47,6 +47,9 @@ export const HqOperationAction = {
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
@@ -99,6 +102,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
STORE_PAYOUT: '门店打款确认',
};
@@ -65,6 +65,12 @@ export class SmsAliyunProvider implements ISmsProvider {
) {
return this.config.aliyunSmsRedeemConfirmTemplateCode;
}
if (
scene === 'PARTNER_PROXY_ORDER' &&
this.config.aliyunSmsProxyOrderTemplateCode
) {
return this.config.aliyunSmsProxyOrderTemplateCode;
}
return this.config.aliyunSmsTemplateCode;
}
@@ -1,11 +1,11 @@
import { 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';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@@ -19,12 +19,15 @@ export class AnalyticsController {
@Controller('promo')
export class PromoController {
constructor(private readonly analyticsService: AnalyticsService) {}
constructor(private readonly promoCodeService: PromoCodeService) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.analyticsService.touchPromo(dto.promoCode, userId);
return this.promoCodeService.touch(
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId },
userId,
);
}
}
@@ -1,11 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { PromoModule } from '../promo/promo.module';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [forwardRef(() => IamModule)],
controllers: [AnalyticsController, PromoController],
imports: [forwardRef(() => IamModule), PromoModule], controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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';
@@ -123,43 +123,5 @@ export class AnalyticsService {
extraJson: event.extraJson as never,
};
}
/** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */
async touchPromo(promoCode: string, userId?: bigint) {
const code = promoCode.trim().toUpperCase();
const promo = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (!promo || promo.status !== 'ACTIVE') {
throw new NotFoundException('推广码无效或已停用');
}
await this.prisma.commonPromoCode.update({
where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
let attributed = false;
if (userId) {
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
attributed = true;
}
}
return serializeBigInt({
promoCode: promo.code,
channelName: promo.name,
attributed,
});
}
}
@@ -1,7 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class PromoTouchDto {
@IsOptional()
@IsString()
@IsNotEmpty()
promoCode: string;
promoCode?: string;
@IsOptional()
@IsString()
qrcodeId?: string;
}
@@ -90,6 +90,8 @@ export class AuthService {
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER:
return ClientApp.PARTNER_H5;
default:
return ClientApp.USER_H5;
}
@@ -140,6 +142,13 @@ export class AuthService {
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
default:
return undefined;
}
@@ -279,6 +288,9 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
return;
}
}
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
@@ -286,6 +298,44 @@ export class AuthService {
await this.smsProvider.verify(normalizedPhone, code, scene);
}
/** 合伙人代下单:按手机号查找或创建已验证用户 */
async findOrCreateUserByPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
include: { avatar: true },
});
if (!user) {
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
});
} else {
if (!user.phoneVerifiedAt) {
user = await this.prisma.user.update({
where: { id: user.id },
data: { phoneVerifiedAt: new Date() },
include: { avatar: true },
});
}
await this.assertActiveUser(user.id);
}
return user;
}
private async verifySmsForUser(
phone: string,
code: string,
@@ -1261,6 +1311,21 @@ export class AuthService {
}
}
if (
guest.sourceType === 'PROMO_CODE' &&
guest.sourceRefId &&
primary.sourceType === 'ORGANIC'
) {
await tx.user.update({
where: { id: primaryId },
data: {
sourceType: 'PROMO_CODE',
sourceRefId: guest.sourceRefId,
sourceLabel: guest.sourceLabel ?? primary.sourceLabel,
},
});
}
const deviceKeyToTransfer =
guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
if (deviceKeyToTransfer) {
@@ -1,36 +0,0 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodesService } from './admin-promo-codes.service';
import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
import { CreatePromoCodeDto, UpdatePromoCodeStatusDto } from './dto/admin-mutate.dto';
@Controller('admin/promo-codes')
@UseGuards(HqAuthGuard)
export class AdminPromoCodesController {
constructor(private readonly service: AdminPromoCodesService) {}
@Get()
list(@Query() query: AdminPromoCodesQueryDto) {
return this.service.list(query);
}
@Get(':id/stats')
stats(@Param('id') id: string) {
return this.service.stats(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
create(@Body() dto: CreatePromoCodeDto) {
return this.service.create(dto);
}
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
return this.service.updateStatus(BigInt(id), dto.status);
}
}
@@ -1,133 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CreatePromoCodeDto } from './dto/admin-mutate.dto';
import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
function userH5Base(): string {
return (process.env.USER_H5_URL || 'http://localhost:5173/user').replace(/\/$/, '');
}
function buildLandingUrl(code: string): string {
return `${userH5Base()}/?promo=${encodeURIComponent(code)}`;
}
function randomCode(): string {
const n = Math.random().toString(36).slice(2, 8).toUpperCase();
return `DK${n}`;
}
@Injectable()
export class AdminPromoCodesService {
constructor(private readonly prisma: PrismaService) {}
private mapRow(row: {
id: bigint;
code: string;
name: string;
status: string;
scanCount: number;
orderCount: number;
createdAt: Date;
}) {
return serializeBigInt({
id: row.id,
code: row.code,
name: row.name,
status: row.status,
scanCount: row.scanCount,
orderCount: row.orderCount,
landingUrl: buildLandingUrl(row.code),
createdAt: row.createdAt,
});
}
async list(query: AdminPromoCodesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
status?: 'ACTIVE' | 'DISABLED';
name?: { contains: string };
code?: { contains: string };
} = {};
if (query.status) where.status = query.status as 'ACTIVE' | 'DISABLED';
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonPromoCode.count({ where }),
]);
return serializeBigInt({
items: items.map((r) => this.mapRow(r)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
const stats = this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row), stats });
}
async create(dto: CreatePromoCodeDto) {
let code = dto.code?.trim().toUpperCase();
if (code) {
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (exists) throw new BadRequestException('推广码已存在');
} else {
for (let i = 0; i < 5; i++) {
const candidate = randomCode();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
if (!exists) {
code = candidate;
break;
}
}
if (!code) throw new BadRequestException('生成推广码失败,请重试');
}
const row = await this.prisma.commonPromoCode.create({
data: {
code,
name: dto.name.trim(),
status: 'ACTIVE',
},
});
return this.mapRow(row);
}
async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
const row = await this.prisma.commonPromoCode.update({
where: { id },
data: { status },
});
return this.mapRow(row);
}
async stats(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
return serializeBigInt(this.statsFromRow(row));
}
private statsFromRow(row: { scanCount: number; orderCount: number }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
return { scanCount, orderCount, conversionRate };
}
}
@@ -16,6 +16,9 @@ function mapAdminUserRow(u: {
wxOpenId: string | null;
nickname: string | null;
status: number;
sourceType: string;
sourceRefId: bigint | null;
sourceLabel: string | null;
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
@@ -31,6 +34,9 @@ function mapAdminUserRow(u: {
wechatVerified: !!u.wxOpenId,
nickname: u.nickname,
status: u.status,
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
sourceLabel: u.sourceLabel,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
@@ -69,6 +75,9 @@ export class AdminUsersService {
wxOpenId: true,
nickname: true,
status: true,
sourceType: true,
sourceRefId: true,
sourceLabel: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
@@ -107,12 +116,21 @@ export class AdminUsersService {
});
if (!user) throw new NotFoundException('用户不存在');
let sourcePromo: { id: bigint; code: string; name: string } | null = null;
if (user.sourceType === 'PROMO_CODE' && user.sourceRefId) {
sourcePromo = await this.prisma.commonPromoCode.findUnique({
where: { id: user.sourceRefId },
select: { id: true, code: true, name: true },
});
}
return serializeBigInt({
...user,
wechatVerified: !!user.wxOpenId,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
addressCount: user._count.addresses,
sourcePromo,
_count: undefined,
});
}
@@ -33,8 +33,6 @@ import { AdminHqLogsController } from './admin-hq-logs.controller';
import { AdminHqLogsService } from './admin-hq-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { AdminPromoCodesController } from './admin-promo-codes.controller';
import { AdminPromoCodesService } from './admin-promo-codes.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
@@ -79,7 +77,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
AdminPromoCodesController,
AdminWechatBindingsController,
AdminHqPermissionsController,
],
@@ -103,7 +100,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
AdminPromoCodesService,
AdminWechatBindingsService,
AdminHqPermissionsService,
SuperAdminGuard,
@@ -0,0 +1,84 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { PromoCodeService } from './promo-code.service';
import {
CreatePromoCodeDto,
PromoCodeListQueryDto,
UpdatePromoCodeDto,
UpdatePromoCodeStatusDto,
} from './dto/promo-code.dto';
@Controller('admin/promo-codes')
@UseGuards(HqAuthGuard)
export class AdminPromoCodeController {
constructor(private readonly service: PromoCodeService) {}
@Get('scenes')
listScenes() {
return this.service.listScenes();
}
@Get()
list(@Query() query: PromoCodeListQueryDto) {
return this.service.list(query);
}
@Get(':id/users')
listUsers(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listUsers(
BigInt(id),
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
);
}
@Get(':id/stats')
stats(@Param('id') id: string) {
return this.service.stats(BigInt(id));
}
@Get(':id/qrcode')
qrcode(@Param('id') id: string) {
return this.service.getQrcodeUrl(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.PROMO_CODE_CREATE,
refType: 'PROMO_CODE',
batch: true,
includeBody: true,
})
create(@Body() dto: CreatePromoCodeDto) {
return this.service.create(dto);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.PROMO_CODE_UPDATE,
refType: 'PROMO_CODE',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdatePromoCodeDto) {
return this.service.update(BigInt(id), dto);
}
@Put(':id/status')
@HqOperation({
action: HqOperationAction.PROMO_CODE_UPDATE_STATUS,
refType: 'PROMO_CODE',
refIdParam: 'id',
includeBody: true,
})
updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
return this.service.updateStatus(BigInt(id), dto.status);
}
}
@@ -0,0 +1,83 @@
import { Type } from 'class-transformer';
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
export class PromoCodeListQueryDto {
@IsOptional()
@Type(() => Number)
page?: number;
@IsOptional()
@Type(() => Number)
pageSize?: number;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: PromoCodeStatus;
@IsOptional()
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
scene?: PromoCodeScene;
@IsOptional()
@IsString()
ownerUserId?: string;
}
export class CreatePromoCodeDto {
@IsString()
@IsNotEmpty()
@MaxLength(128)
name: string;
@IsOptional()
@IsString()
@MaxLength(32)
code?: string;
@IsOptional()
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
scene?: PromoCodeScene;
@IsOptional()
@IsString()
ownerUserId?: string;
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string;
}
export class UpdatePromoCodeDto {
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
@IsOptional()
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
scene?: PromoCodeScene;
@IsOptional()
@IsString()
ownerUserId?: string | null;
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string | null;
}
export class UpdatePromoCodeStatusDto {
@IsIn(['ACTIVE', 'DISABLED'])
status: PromoCodeStatus;
}
@@ -0,0 +1,515 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import * as QRCode from 'qrcode';
import {
PROMO_CODE_SCENE_LABELS,
PromoCodeScene,
buildPromoLandingUrl,
loadAppConfig,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import type {
CreatePromoCodeDto,
PromoCodeListQueryDto,
UpdatePromoCodeDto,
} from './dto/promo-code.dto';
type PromoRow = {
id: bigint;
code: string;
name: string;
scene: string;
qrcodeId: string;
status: string;
remark: string | null;
scanCount: number;
orderCount: number;
createdAt: Date;
updatedAt: Date;
ownerUser?: {
id: bigint;
userNo: string | null;
nickname: string | null;
phone: string | null;
} | null;
qrcodeResource?: { url: string } | null;
};
function userH5Base(): string {
return loadAppConfig().userH5Url;
}
function buildLandingUrl(code: string, qrcodeId: string): string {
return buildPromoLandingUrl(userH5Base(), code, qrcodeId);
}
function randomPromoCode(): string {
const n = Math.random().toString(36).slice(2, 8).toUpperCase();
return `DK${n}`;
}
function randomQrcodeId(): string {
return randomBytes(32).toString('hex');
}
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? null;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
@Injectable()
export class PromoCodeService {
constructor(
private readonly prisma: PrismaService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
) {}
listScenes() {
return Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }));
}
async listActiveOptions() {
const rows = await this.prisma.commonPromoCode.findMany({
where: { status: 'ACTIVE' },
select: { id: true, code: true, name: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
return serializeBigInt(rows);
}
/** 代下单绑定推广码:归因 + 用户来源(若可写) */
async attributeUserToPromo(userId: bigint, promoId: bigint) {
const promo = await this.prisma.commonPromoCode.findUnique({
where: { id: promoId },
select: { id: true, name: true, status: true },
});
if (!promo || promo.status !== 'ACTIVE') {
throw new BadRequestException('推广码无效或已停用');
}
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
}
await this.applyPromoSourceToUser(userId, promo);
return promo;
}
private mapOwnerUser(user: PromoRow['ownerUser']) {
if (!user) return null;
return serializeBigInt({
id: user.id,
userNo: user.userNo,
nickname: user.nickname,
phone: maskPhone(user.phone),
});
}
private mapRow(row: PromoRow) {
return serializeBigInt({
id: row.id,
code: row.code,
name: row.name,
scene: row.scene,
qrcodeId: row.qrcodeId,
status: row.status,
remark: row.remark,
scanCount: row.scanCount,
orderCount: row.orderCount,
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
qrcodeUrl: row.qrcodeResource?.url ?? null,
ownerUser: this.mapOwnerUser(row.ownerUser),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
private includeRelations = {
ownerUser: {
select: { id: true, userNo: true, nickname: true, phone: true },
},
qrcodeResource: {
select: { url: true },
},
} as const;
async list(query: PromoCodeListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
status?: 'ACTIVE' | 'DISABLED';
scene?: PromoCodeScene;
name?: { contains: string };
code?: { contains: string };
ownerUserId?: bigint;
} = {};
if (query.status) where.status = query.status;
if (query.scene) where.scene = query.scene;
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code.toUpperCase() };
if (query.ownerUserId?.trim()) {
where.ownerUserId = BigInt(query.ownerUserId.trim());
}
const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
where,
include: this.includeRelations,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonPromoCode.count({ where }),
]);
return serializeBigInt({
items: items.map((r) => this.mapRow(r)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({
where: { id },
include: this.includeRelations,
});
if (!row) throw new NotFoundException('推广码不存在');
const stats = await this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row), stats });
}
private async resolveOwnerUserId(ownerUserId?: string) {
if (!ownerUserId?.trim()) return undefined;
const user = await this.prisma.user.findFirst({
where: { id: BigInt(ownerUserId.trim()), mergedIntoUserId: null, status: 1 },
select: { id: true },
});
if (!user) throw new BadRequestException('关联用户不存在');
return user.id;
}
private async generateUniqueCode(custom?: string) {
let code = custom?.trim().toUpperCase();
if (code) {
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (exists) throw new BadRequestException('推广码已存在');
return code;
}
for (let i = 0; i < 8; i++) {
const candidate = randomPromoCode();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
if (!exists) return candidate;
}
throw new BadRequestException('生成推广码失败,请重试');
}
private async generateUniqueQrcodeId() {
for (let i = 0; i < 8; i++) {
const candidate = randomQrcodeId();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { qrcodeId: candidate } });
if (!exists) return candidate;
}
throw new BadRequestException('生成二维码 ID 失败,请重试');
}
private async createQrcodeResource(promoId: bigint, code: string, qrcodeId: string) {
const landingUrl = buildLandingUrl(code, qrcodeId);
const pngBuffer = await QRCode.toBuffer(landingUrl, {
width: 512,
margin: 1,
type: 'png',
color: { dark: '#1f1a17', light: '#ffffff' },
});
const uploaded = await this.oss.putObject({
bizType: 'QRCODE',
mediaType: 'IMAGE',
fileName: `promo-${code}.png`,
buffer: pngBuffer,
mimeType: 'image/png',
});
const resource = await this.prisma.commonResource.create({
data: {
ownerType: 'PROMO',
ownerId: promoId,
bizType: 'QRCODE',
mediaType: 'IMAGE',
ossBucket: uploaded.bucket,
ossKey: uploaded.ossKey,
url: uploaded.url,
fileName: `promo-${code}.png`,
fileSize: BigInt(pngBuffer.length),
mimeType: 'image/png',
},
});
return resource;
}
async create(dto: CreatePromoCodeDto) {
const code = await this.generateUniqueCode(dto.code);
const qrcodeId = await this.generateUniqueQrcodeId();
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
const row = await this.prisma.commonPromoCode.create({
data: {
code,
name: dto.name.trim(),
scene,
qrcodeId,
status: 'ACTIVE',
ownerUserId,
remark: dto.remark?.trim() || null,
},
});
const resource = await this.createQrcodeResource(row.id, code, qrcodeId);
const updated = await this.prisma.commonPromoCode.update({
where: { id: row.id },
data: { qrcodeResourceId: resource.id },
include: this.includeRelations,
});
return this.mapRow(updated);
}
async update(id: bigint, dto: UpdatePromoCodeDto) {
await this.detail(id);
const data: {
name?: string;
scene?: PromoCodeScene;
remark?: string | null;
ownerUserId?: bigint | null;
} = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if (dto.scene !== undefined) data.scene = dto.scene;
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
if (dto.ownerUserId !== undefined) {
if (dto.ownerUserId === null || dto.ownerUserId === '') {
data.ownerUserId = null;
} else {
data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
}
}
const row = await this.prisma.commonPromoCode.update({
where: { id },
data,
include: this.includeRelations,
});
return this.mapRow(row);
}
async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
const row = await this.prisma.commonPromoCode.update({
where: { id },
data: { status },
include: this.includeRelations,
});
return this.mapRow(row);
}
async stats(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
return serializeBigInt(await this.statsFromRow(row));
}
async getQrcodeUrl(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({
where: { id },
include: { qrcodeResource: { select: { url: true } } },
});
if (!row) throw new NotFoundException('推广码不存在');
if (!row.qrcodeResource?.url) {
throw new NotFoundException('二维码资源不存在,请重新生成推广码');
}
return {
qrcodeUrl: row.qrcodeResource.url,
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
name: row.name,
code: row.code,
};
}
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string }) {
const code = input.code?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
if (code) {
return this.prisma.commonPromoCode.findUnique({ where: { code } });
}
if (qrcodeId) {
return this.prisma.commonPromoCode.findUnique({ where: { qrcodeId } });
}
return null;
}
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
async touch(input: { promoCode?: string; qrcodeId?: string }, userId?: bigint) {
const promoCode = input.promoCode?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
if (!promoCode && !qrcodeId) {
throw new BadRequestException('请提供 promoCode 或 qrcodeId');
}
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId });
if (!promo || promo.status !== 'ACTIVE') {
throw new NotFoundException('推广码无效或已停用');
}
await this.prisma.commonPromoCode.update({
where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
let attributed = false;
let sourceApplied = false;
if (userId) {
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
attributed = true;
}
sourceApplied = await this.applyPromoSourceToUser(userId, promo);
}
return serializeBigInt({
promoCode: promo.code,
promoCodeId: promo.id,
channelName: promo.name,
attributed,
sourceApplied,
});
}
/** 用户来源:PROMO_CODE + 推广码 ID(仅 ORGANIC 可写入,不覆盖已有来源) */
async applyPromoSourceToUser(
userId: bigint,
promo: { id: bigint; name: string },
): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { sourceType: true, mergedIntoUserId: true, status: true },
});
if (!user || user.mergedIntoUserId || user.status !== 1 || user.sourceType !== 'ORGANIC') {
return false;
}
await this.prisma.user.update({
where: { id: userId },
data: {
sourceType: 'PROMO_CODE',
sourceRefId: promo.id,
sourceLabel: promo.name,
},
});
return true;
}
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
const [attributionCount, sourceMarkedCount] = await Promise.all([
this.prisma.userPromoAttribution.count({
where: { promoCodeId: row.id },
}),
this.prisma.user.count({
where: {
sourceType: 'PROMO_CODE',
sourceRefId: row.id,
mergedIntoUserId: null,
},
}),
]);
return { scanCount, orderCount, conversionRate, attributionCount, sourceMarkedCount };
}
/** 推广码关联用户:归因记录或用户来源指向本码 */
async listUsers(promoId: bigint, page = 1, pageSize = 20) {
const promo = await this.prisma.commonPromoCode.findUnique({
where: { id: promoId },
select: { id: true },
});
if (!promo) throw new NotFoundException('推广码不存在');
const where = {
mergedIntoUserId: null,
OR: [
{ promoTouch: { promoCodeId: promoId } },
{ sourceType: 'PROMO_CODE' as const, sourceRefId: promoId },
],
};
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
userNo: true,
nickname: true,
phone: true,
phoneVerifiedAt: true,
sourceType: true,
sourceRefId: true,
createdAt: true,
promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
_count: { select: { orders: true } },
},
}),
this.prisma.user.count({ where }),
]);
return serializeBigInt({
items: items.map((u) => ({
id: u.id,
userNo: u.userNo,
nickname: u.nickname,
phone: maskPhone(u.phone),
phoneVerifiedAt: u.phoneVerifiedAt,
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
firstTouchAt: u.promoTouch?.promoCodeId.toString() === promoId.toString()
? u.promoTouch.firstTouchAt
: null,
orderCount: u._count.orders,
createdAt: u.createdAt,
})),
total,
page,
pageSize,
});
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodeController } from './admin-promo-code.controller';
import { PromoCodeService } from './promo-code.service';
@Module({
imports: [
IntegrationsModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}),
],
controllers: [AdminPromoCodeController],
providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
exports: [PromoCodeService],
})
export class PromoModule {}
@@ -0,0 +1,71 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
export class PartnerProxyOrderPreviewDto {
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
receiverCity?: string;
@IsOptional()
@IsString()
receiverDistrict?: string;
}
export class PartnerProxyOrderSendSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
}
export class PartnerProxyOrderCreateDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@IsString()
@IsNotEmpty()
smsCode: string;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@IsString()
@MaxLength(32)
province: string;
@IsString()
@MaxLength(32)
city: string;
@IsString()
@MaxLength(32)
district: string;
@IsString()
@MaxLength(256)
addressDetail: string;
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
promoCodeId?: string;
}
@@ -5,6 +5,11 @@ import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto,
} from './dto/partner-proxy-order.dto';
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
@@ -106,3 +111,33 @@ export class PartnerReshipmentController {
return this.tradeService.listPartnerReshipments(user.actorId);
}
}
@Controller('partner/proxy-orders')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getPartnerProxyOrderOptions();
}
@Post('preview')
preview(@Body() dto: PartnerProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto);
}
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
}
@Post()
create(
@CurrentUser() user: AuthUser,
@Body() dto: PartnerProxyOrderCreateDto,
@Req() req: Request,
) {
return this.tradeService.createPartnerProxyOrder(user.actorId, dto, req);
}
}
@@ -6,12 +6,32 @@ import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { PromoModule } from '../promo/promo.module';
import {
TradeController,
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
} from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
imports: [
IntegrationsModule,
IamModule,
CatalogModule,
AnalyticsModule,
CityScopeModule,
PromoModule,
forwardRef(() => BenefitModule),
CommonModule,
],
controllers: [
TradeController,
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
],
providers: [TradeService],
exports: [TradeService],
})
@@ -11,13 +11,15 @@ import {
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { loadAppConfig, ClientApp, SmsScene, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -41,6 +43,8 @@ export class TradeService {
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly authService: AuthService,
private readonly promoCodeService: PromoCodeService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -584,4 +588,225 @@ export class TradeService {
});
});
}
async getPartnerProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
this.catalogService.listProducts(),
this.promoCodeService.listActiveOptions(),
]);
return serializeBigInt({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
})),
promoCodes,
});
}
async previewPartnerProxyOrder(body: {
productId: string;
quantity: number;
receiverCity?: string;
receiverDistrict?: string;
}) {
const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
const check = validateMinPurchase(
deliveryType,
body.quantity,
city.localMinQty,
city.crossMinQty,
);
if (!check.ok) throw new BadRequestException(check.message);
const unitPrice = Number(product.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
return {
productAmount,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
deliveryType,
unitPrice,
};
}
async sendPartnerProxyOrderSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
normalizedPhone.length >= 7
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
: normalizedPhone;
return { ok: true, maskedPhone: masked };
}
async createPartnerProxyOrder(
partnerAccountId: bigint,
body: {
phone: string;
smsCode: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
productId: string;
quantity: number;
promoCodeId?: string;
},
req: Request,
) {
const normalizedPhone = body.phone.trim();
await this.authService.verifySmsCode(
normalizedPhone,
body.smsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone);
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
receiverCity: body.city,
receiverDistrict: body.district,
});
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district);
let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) {
promoCodeId = BigInt(body.promoCodeId.trim());
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
}
const receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
const receiverAddress = `${body.province}${body.city}${body.district}${body.addressDetail}`;
const orderNo = generateOrderNo();
const now = new Date();
const location = buildOrderClientLocationSnapshot(
req,
this.ipGeoService.resolve(extractClientIp(req)),
undefined,
);
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNo,
orderType: 'PROXY',
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: preview.deliveryType,
channelSource: 'OFFLINE_PROXY',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
freightAmount: 0,
freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null,
receiverName,
receiverPhone: normalizedPhone,
receiverAddress,
receiverProvince: body.province,
receiverCity: body.city,
receiverDistrict: body.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
paidAt: now,
shippedAt: now,
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
remark: `合伙人代下单 partnerAccountId=${primary.id}`,
},
include: { product: true, imageResource: true },
});
await tx.orderDelivery.create({
data: {
orderId: created.id,
provider: 'MANUAL',
outWarehouseAt: now,
shippingAt: now,
deliveredAt: now,
},
});
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId: created.id,
fromStatus: 'PENDING_PAY',
toStatus: 'COMPLETED',
operator: 'PARTNER_PROXY',
remark: '合伙人线下代下单',
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
await this.benefitService.grantOnOrderPaid(order.id);
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_proxy_order_create',
refType: 'ORDER',
refId: order.id,
extraJson: {
orderId: order.id.toString(),
userId: user.id.toString(),
productId: body.productId,
quantity: body.quantity,
promoCodeId: promoCodeId?.toString() ?? null,
},
});
return this.getPartnerOrder(partnerAccountId, order.id);
}
}