代下单功能
This commit is contained in:
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
|
|||||||
import CityPartnersPage from './pages/CityPartnersPage';
|
import CityPartnersPage from './pages/CityPartnersPage';
|
||||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
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 ProductsPage from './pages/ProductsPage';
|
||||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||||
import ResourcesPage from './pages/ResourcesPage';
|
import ResourcesPage from './pages/ResourcesPage';
|
||||||
@@ -52,6 +56,11 @@ export default function App() {
|
|||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<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="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
<Route path="/stores" element={<StoresPage />} />
|
<Route path="/stores" element={<StoresPage />} />
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
|
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||||
{
|
{
|
||||||
key: 'stores-group',
|
key: 'stores-group',
|
||||||
icon: <ShopOutlined />,
|
icon: <ShopOutlined />,
|
||||||
@@ -118,7 +119,9 @@ export default function AdminLayout() {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedKey = location.pathname;
|
const selectedKey = location.pathname.startsWith('/promo-codes')
|
||||||
|
? '/promo-codes'
|
||||||
|
: location.pathname;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export type AdminUserRow = {
|
|||||||
wechatVerified: boolean;
|
wechatVerified: boolean;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
|
sourceType: string;
|
||||||
|
sourceRefId: string | null;
|
||||||
|
sourceLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { request, type AdminUserRow, type Paginated } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ type UserDetail = AdminUserRow & {
|
|||||||
wxUnionId?: string | null;
|
wxUnionId?: string | null;
|
||||||
cityPref?: Record<string, unknown> | null;
|
cityPref?: Record<string, unknown> | null;
|
||||||
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
|
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
|
||||||
|
sourcePromo?: { id: string; code: string; name: string } | null;
|
||||||
orders?: UserOrderRow[];
|
orders?: UserOrderRow[];
|
||||||
mergedFromCount?: number;
|
mergedFromCount?: number;
|
||||||
addressCount?: number;
|
addressCount?: number;
|
||||||
@@ -62,6 +64,7 @@ type BatchDeletePreview = {
|
|||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -105,6 +108,14 @@ export default function UsersPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [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) {
|
async function openDetail(id: string) {
|
||||||
const res = await request<UserDetail>(`/admin/users/${id}`);
|
const res = await request<UserDetail>(`/admin/users/${id}`);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
@@ -225,6 +236,40 @@ export default function UsersPage() {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (v) => (v ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>),
|
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',
|
title: 'deviceKey',
|
||||||
dataIndex: 'deviceKey',
|
dataIndex: 'deviceKey',
|
||||||
@@ -307,7 +352,7 @@ export default function UsersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1500 }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
preserveSelectedRowKeys: true,
|
preserveSelectedRowKeys: true,
|
||||||
@@ -347,6 +392,28 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="微信验证">
|
<Descriptions.Item label="微信验证">
|
||||||
{detail.wechatVerified ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>}
|
{detail.wechatVerified ? <Tag color="blue">微信已验证</Tag> : <Tag>未绑定</Tag>}
|
||||||
</Descriptions.Item>
|
</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="wxOpenId">{detail.wxOpenId || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
|
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</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); },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import BillsPage from './pages/BillsPage';
|
|||||||
import SettlementPage from './pages/SettlementPage';
|
import SettlementPage from './pages/SettlementPage';
|
||||||
import ReshipPage from './pages/ReshipPage';
|
import ReshipPage from './pages/ReshipPage';
|
||||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import ProxyOrderPage from './pages/ProxyOrderPage';
|
||||||
import StaffListPage from './pages/StaffListPage';
|
import StaffListPage from './pages/StaffListPage';
|
||||||
import StaffCreatePage from './pages/StaffCreatePage';
|
import StaffCreatePage from './pages/StaffCreatePage';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ function PrimaryRoutes() {
|
|||||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||||
<Route path="/center/staff" element={<StaffListPage />} />
|
<Route path="/center/staff" element={<StaffListPage />} />
|
||||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||||
|
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||||
<Route path="/reshipments" element={<ReshipPage />} />
|
<Route path="/reshipments" element={<ReshipPage />} />
|
||||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<section className="partner-bento-card">
|
<section className="partner-bento-card">
|
||||||
<div className="partner-quick-actions">
|
<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">
|
<Link to="/stores/new" className="partner-quick-action">
|
||||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3031,3 +3031,28 @@ body {
|
|||||||
color: var(--color-outline, #8d706e);
|
color: var(--color-outline, #8d706e);
|
||||||
pointer-events: none;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,13 +20,12 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
|||||||
import PayPage from './pages/PayPage';
|
import PayPage from './pages/PayPage';
|
||||||
import CustomerServicePage from './pages/CustomerServicePage';
|
import CustomerServicePage from './pages/CustomerServicePage';
|
||||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||||
import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
|
import { capturePromoFromUrl } from './lib/promo';
|
||||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||||
|
|
||||||
function PromoBootstrap() {
|
function PromoBootstrap() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
capturePromoFromUrl();
|
capturePromoFromUrl();
|
||||||
void touchPromoIfNeeded();
|
|
||||||
}, []);
|
}, []);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
import { apiBase } from './api';
|
import { apiBase } from './api';
|
||||||
|
|
||||||
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
|
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 params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||||
const code = params.get('promo')?.trim();
|
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 {
|
export function capturePromoFromUrl(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
let code = readPromoFromSearch(window.location.search);
|
let parsed = readPromoFromSearch(window.location.search);
|
||||||
if (!code && window.location.hash.includes('?')) {
|
if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
|
||||||
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
||||||
code = readPromoFromSearch(hashQuery);
|
parsed = readPromoFromSearch(hashQuery);
|
||||||
}
|
}
|
||||||
if (code) {
|
if (parsed.code) {
|
||||||
sessionStorage.setItem(PROMO_STORAGE_KEY, 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 {
|
export function getStoredPromoCode(): string | null {
|
||||||
@@ -27,10 +35,16 @@ export function getStoredPromoCode(): string | null {
|
|||||||
return sessionStorage.getItem(PROMO_STORAGE_KEY);
|
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) */
|
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
|
||||||
export async function touchPromoIfNeeded(): Promise<void> {
|
export async function touchPromoIfNeeded(): Promise<void> {
|
||||||
const promoCode = getStoredPromoCode();
|
const promoCode = getStoredPromoCode();
|
||||||
if (!promoCode) return;
|
const qrcodeId = getStoredPromoPid();
|
||||||
|
if (!promoCode && !qrcodeId) return;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -43,7 +57,10 @@ export async function touchPromoIfNeeded(): Promise<void> {
|
|||||||
const res = await fetch(`${apiBase}/promo/touch`, {
|
const res = await fetch(`${apiBase}/promo/touch`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ promoCode }),
|
body: JSON.stringify({
|
||||||
|
...(promoCode ? { promoCode } : {}),
|
||||||
|
...(qrcodeId ? { qrcodeId } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.code !== 0) return;
|
if (json.code !== 0) return;
|
||||||
|
|||||||
@@ -18,12 +18,19 @@ export interface AppConfig {
|
|||||||
aliyunSmsTemplateCode: string;
|
aliyunSmsTemplateCode: string;
|
||||||
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 aliyunSmsTemplateCode */
|
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 aliyunSmsTemplateCode */
|
||||||
aliyunSmsRedeemConfirmTemplateCode: string;
|
aliyunSmsRedeemConfirmTemplateCode: string;
|
||||||
|
/** 合伙人代下单短信模板(PARTNER_PROXY_ORDER / 线下代发货);未配置时回退 aliyunSmsTemplateCode */
|
||||||
|
aliyunSmsProxyOrderTemplateCode: string;
|
||||||
aliyunSmsAccessKeyId: string;
|
aliyunSmsAccessKeyId: string;
|
||||||
aliyunSmsAccessKeySecret: string;
|
aliyunSmsAccessKeySecret: string;
|
||||||
/** 腾讯位置服务 Key(逆地理编码) */
|
/** 腾讯位置服务 Key(逆地理编码) */
|
||||||
tencentLbsKey: string;
|
tencentLbsKey: string;
|
||||||
|
/** C 端 H5 落地页(推广码二维码链接前缀) */
|
||||||
|
userH5Url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
|
||||||
|
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
||||||
|
|
||||||
/** 总部客服电话(C 端联系客服) */
|
/** 总部客服电话(C 端联系客服) */
|
||||||
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
|
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 ?? '',
|
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
|
||||||
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
|
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
|
||||||
aliyunSmsRedeemConfirmTemplateCode: e.ALIYUN_SMS_REDEEM_CONFIRM_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 ?? '',
|
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
|
||||||
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
|
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
|
||||||
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
||||||
|
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ export enum ActorType {
|
|||||||
HQ = 'HQ',
|
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 {
|
export enum SmsScene {
|
||||||
USER_LOGIN = 'USER_LOGIN',
|
USER_LOGIN = 'USER_LOGIN',
|
||||||
STORE_LOGIN = 'STORE_LOGIN',
|
STORE_LOGIN = 'STORE_LOGIN',
|
||||||
@@ -27,8 +46,22 @@ export enum SmsScene {
|
|||||||
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
|
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
|
||||||
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
|
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
|
||||||
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
|
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 {
|
export enum OrderStatus {
|
||||||
PENDING_PAY = 'PENDING_PAY',
|
PENDING_PAY = 'PENDING_PAY',
|
||||||
PENDING_SHIP = 'PENDING_SHIP',
|
PENDING_SHIP = 'PENDING_SHIP',
|
||||||
|
|||||||
@@ -1,24 +1,87 @@
|
|||||||
export type PromoCodeStatus = 'ACTIVE' | 'DISABLED';
|
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 = {
|
export type PromoCodeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
scene: PromoCodeScene;
|
||||||
|
qrcodeId: string;
|
||||||
status: PromoCodeStatus;
|
status: PromoCodeStatus;
|
||||||
scanCount: number;
|
scanCount: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
landingUrl: string;
|
landingUrl: string;
|
||||||
|
qrcodeUrl?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
ownerUser?: PromoCodeOwnerUser | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PromoCodeStats = {
|
export type PromoCodeStats = {
|
||||||
scanCount: number;
|
scanCount: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
conversionRate: 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 = {
|
export type PromoTouchResult = {
|
||||||
promoCode: string;
|
promoCode: string;
|
||||||
|
promoCodeId: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
/** 是否首次写入 user_promo_attribution */
|
||||||
attributed: boolean;
|
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)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,3 +33,50 @@ export interface DeliveryDto {
|
|||||||
shippingAt?: string;
|
shippingAt?: string;
|
||||||
deliveredAt?: 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;
|
||||||
|
};
|
||||||
|
|||||||
Generated
+13
@@ -356,6 +356,9 @@ importers:
|
|||||||
ip2region:
|
ip2region:
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.0(@types/node@20.19.43)
|
version: 2.3.0(@types/node@20.19.43)
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@@ -381,6 +384,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.14.0
|
specifier: ^20.14.0
|
||||||
version: 20.19.43
|
version: 20.19.43
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^5.18.0
|
specifier: ^5.18.0
|
||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
@@ -2492,6 +2498,9 @@ packages:
|
|||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/qs@6.15.1':
|
'@types/qs@6.15.1':
|
||||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||||
|
|
||||||
@@ -8827,6 +8836,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
'@types/qs@6.15.1': {}
|
'@types/qs@6.15.1': {}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ ALIYUN_SMS_SIGN_NAME=
|
|||||||
ALIYUN_SMS_TEMPLATE_CODE=
|
ALIYUN_SMS_TEMPLATE_CODE=
|
||||||
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
|
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
|
||||||
ALIYUN_SMS_REDEEM_CONFIRM_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_ID=
|
||||||
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||||
MOCK_PAY=true
|
MOCK_PAY=true
|
||||||
@@ -27,9 +29,9 @@ MOCK_WECHAT=true
|
|||||||
# 登录后是否走微信 SDK OAuth 授权(本地 false 可仅用短信登录/核销,不影响支付 Mock)
|
# 登录后是否走微信 SDK OAuth 授权(本地 false 可仅用短信登录/核销,不影响支付 Mock)
|
||||||
WX_AUTHORIZE=false
|
WX_AUTHORIZE=false
|
||||||
|
|
||||||
# C 端 H5 落地页(推广码二维码链接前缀)
|
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL)
|
||||||
# 本地开发:http://localhost:5173/user 生产统一入口:https://user.runxian.top/user
|
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
|
||||||
USER_H5_URL=http://localhost:5173/user
|
# USER_H5_URL=https://user.runxian.top/user
|
||||||
|
|
||||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||||
TRUST_PROXY=true
|
TRUST_PROXY=true
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"ip2region": "^2.3.0",
|
"ip2region": "^2.3.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"prisma": "^5.18.0",
|
"prisma": "^5.18.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
|
|||||||
@@ -139,13 +139,22 @@ CREATE TABLE common_promo_code (
|
|||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(32) NOT NULL,
|
code VARCHAR(32) NOT NULL,
|
||||||
name VARCHAR(128) 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',
|
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',
|
qrcode_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '小程序码 common_resource.id',
|
||||||
scan_count INT NOT NULL DEFAULT 0,
|
scan_count INT NOT NULL DEFAULT 0,
|
||||||
order_count INT NOT NULL DEFAULT 0,
|
order_count INT NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
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),
|
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='推广码';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
|
||||||
|
|
||||||
-- ===================== PARTNER(城市合伙人主账号 + 子账号) =============================
|
-- ===================== 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());
|
||||||
@@ -100,6 +100,14 @@ enum PromoCodeStatus {
|
|||||||
DISABLED
|
DISABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PromoCodeScene {
|
||||||
|
ONLINE_LINK
|
||||||
|
OFFLINE_PICKUP
|
||||||
|
PARTNER_CHANNEL
|
||||||
|
EVENT
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
enum CityStatus {
|
enum CityStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ACTIVE
|
ACTIVE
|
||||||
@@ -168,6 +176,7 @@ enum UserSourceType {
|
|||||||
enum OrderType {
|
enum OrderType {
|
||||||
NORMAL
|
NORMAL
|
||||||
RESHIPMENT
|
RESHIPMENT
|
||||||
|
PROXY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrderStatus {
|
enum OrderStatus {
|
||||||
@@ -392,16 +401,24 @@ model CommonPromoCode {
|
|||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
|
scene PromoCodeScene @default(ONLINE_LINK)
|
||||||
|
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
|
||||||
status PromoCodeStatus @default(ACTIVE)
|
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
|
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
|
||||||
scanCount Int @default(0) @map("scan_count")
|
scanCount Int @default(0) @map("scan_count")
|
||||||
orderCount Int @default(0) @map("order_count")
|
orderCount Int @default(0) @map("order_count")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
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)
|
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
||||||
attributions UserPromoAttribution[]
|
attributions UserPromoAttribution[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
|
||||||
|
@@index([ownerUserId])
|
||||||
|
@@index([scene, status])
|
||||||
@@map("common_promo_code")
|
@@map("common_promo_code")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,6 +604,7 @@ model User {
|
|||||||
addresses UserAddress[]
|
addresses UserAddress[]
|
||||||
cityPreference UserCityPreference?
|
cityPreference UserCityPreference?
|
||||||
promoTouch UserPromoAttribution?
|
promoTouch UserPromoAttribution?
|
||||||
|
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
||||||
orders Order[]
|
orders Order[]
|
||||||
benefitCoupons BenefitCoupon[]
|
benefitCoupons BenefitCoupon[]
|
||||||
redeemRecords RedeemRecord[]
|
redeemRecords RedeemRecord[]
|
||||||
|
|||||||
@@ -589,6 +589,10 @@ async function main() {
|
|||||||
|
|
||||||
name: '总部品鉴会',
|
name: '总部品鉴会',
|
||||||
|
|
||||||
|
scene: 'EVENT',
|
||||||
|
|
||||||
|
qrcodeId: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||||
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -605,6 +609,10 @@ async function main() {
|
|||||||
|
|
||||||
name: '郑州品鉴会演示',
|
name: '郑州品鉴会演示',
|
||||||
|
|
||||||
|
scene: 'OFFLINE_PICKUP',
|
||||||
|
|
||||||
|
qrcodeId: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
|
||||||
|
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module';
|
|||||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||||
import { JobsModule } from './jobs/jobs.module';
|
import { JobsModule } from './jobs/jobs.module';
|
||||||
import { OpsModule } from './modules/ops/ops.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 { CityScopeModule } from './modules/city-scope/city-scope.module';
|
||||||
import { CommonModule } from './modules/common/common.module';
|
import { CommonModule } from './modules/common/common.module';
|
||||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||||
@@ -42,6 +43,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
|
|||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
OpsModule,
|
OpsModule,
|
||||||
|
PromoModule,
|
||||||
CityScopeModule,
|
CityScopeModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
HqOperationModule,
|
HqOperationModule,
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const HqOperationAction = {
|
|||||||
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
|
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
|
||||||
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
||||||
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
|
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.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
|
||||||
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
||||||
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
||||||
|
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
||||||
|
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
|
||||||
|
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
|
||||||
STORE_PAYOUT: '门店打款确认',
|
STORE_PAYOUT: '门店打款确认',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export class SmsAliyunProvider implements ISmsProvider {
|
|||||||
) {
|
) {
|
||||||
return this.config.aliyunSmsRedeemConfirmTemplateCode;
|
return this.config.aliyunSmsRedeemConfirmTemplateCode;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
scene === 'PARTNER_PROXY_ORDER' &&
|
||||||
|
this.config.aliyunSmsProxyOrderTemplateCode
|
||||||
|
) {
|
||||||
|
return this.config.aliyunSmsProxyOrderTemplateCode;
|
||||||
|
}
|
||||||
return this.config.aliyunSmsTemplateCode;
|
return this.config.aliyunSmsTemplateCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AnalyticsService } from './analytics.service';
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
import { PromoCodeService } from '../promo/promo-code.service';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { PromoTouchDto } from './dto/promo.dto';
|
import { PromoTouchDto } from './dto/promo.dto';
|
||||||
import { ActorType } from '@dukang/shared-types';
|
import { ActorType } from '@dukang/shared-types';
|
||||||
|
|
||||||
@Controller('analytics')
|
@Controller('analytics')
|
||||||
export class AnalyticsController {
|
export class AnalyticsController {
|
||||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||||
@@ -19,12 +19,15 @@ export class AnalyticsController {
|
|||||||
|
|
||||||
@Controller('promo')
|
@Controller('promo')
|
||||||
export class PromoController {
|
export class PromoController {
|
||||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
constructor(private readonly promoCodeService: PromoCodeService) {}
|
||||||
|
|
||||||
@Post('touch')
|
@Post('touch')
|
||||||
@UseGuards(OptionalJwtAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
||||||
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
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 { Module, forwardRef } from '@nestjs/common';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
|
import { PromoModule } from '../promo/promo.module';
|
||||||
import { AnalyticsController, PromoController } from './analytics.controller';
|
import { AnalyticsController, PromoController } from './analytics.controller';
|
||||||
import { AnalyticsService } from './analytics.service';
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => IamModule)],
|
imports: [forwardRef(() => IamModule), PromoModule], controllers: [AnalyticsController, PromoController],
|
||||||
controllers: [AnalyticsController, PromoController],
|
|
||||||
providers: [AnalyticsService],
|
providers: [AnalyticsService],
|
||||||
exports: [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 type { ClientApp } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -123,43 +123,5 @@ export class AnalyticsService {
|
|||||||
extraJson: event.extraJson as never,
|
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 {
|
export class PromoTouchDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@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_LOOKUP:
|
||||||
case SmsScene.REDEEM_PHONE_CONFIRM:
|
case SmsScene.REDEEM_PHONE_CONFIRM:
|
||||||
return ClientApp.SHOP_H5;
|
return ClientApp.SHOP_H5;
|
||||||
|
case SmsScene.PARTNER_PROXY_ORDER:
|
||||||
|
return ClientApp.PARTNER_H5;
|
||||||
default:
|
default:
|
||||||
return ClientApp.USER_H5;
|
return ClientApp.USER_H5;
|
||||||
}
|
}
|
||||||
@@ -140,6 +142,13 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
return user ? { refType: 'USER', refId: user.id } : undefined;
|
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:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -279,6 +288,9 @@ export class AuthService {
|
|||||||
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
|
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
|
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
|
||||||
@@ -286,6 +298,44 @@ export class AuthService {
|
|||||||
await this.smsProvider.verify(normalizedPhone, code, scene);
|
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(
|
private async verifySmsForUser(
|
||||||
phone: string,
|
phone: string,
|
||||||
code: 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 =
|
const deviceKeyToTransfer =
|
||||||
guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
|
guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
|
||||||
if (deviceKeyToTransfer) {
|
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;
|
wxOpenId: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
|
sourceType: string;
|
||||||
|
sourceRefId: bigint | null;
|
||||||
|
sourceLabel: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
_count: { orders: number };
|
_count: { orders: number };
|
||||||
@@ -31,6 +34,9 @@ function mapAdminUserRow(u: {
|
|||||||
wechatVerified: !!u.wxOpenId,
|
wechatVerified: !!u.wxOpenId,
|
||||||
nickname: u.nickname,
|
nickname: u.nickname,
|
||||||
status: u.status,
|
status: u.status,
|
||||||
|
sourceType: u.sourceType,
|
||||||
|
sourceRefId: u.sourceRefId,
|
||||||
|
sourceLabel: u.sourceLabel,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
orderCount: u._count.orders,
|
orderCount: u._count.orders,
|
||||||
@@ -69,6 +75,9 @@ export class AdminUsersService {
|
|||||||
wxOpenId: true,
|
wxOpenId: true,
|
||||||
nickname: true,
|
nickname: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
sourceType: true,
|
||||||
|
sourceRefId: true,
|
||||||
|
sourceLabel: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
_count: { select: { orders: true } },
|
_count: { select: { orders: true } },
|
||||||
@@ -107,12 +116,21 @@ export class AdminUsersService {
|
|||||||
});
|
});
|
||||||
if (!user) throw new NotFoundException('用户不存在');
|
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({
|
return serializeBigInt({
|
||||||
...user,
|
...user,
|
||||||
wechatVerified: !!user.wxOpenId,
|
wechatVerified: !!user.wxOpenId,
|
||||||
mergedFromCount: user._count.mergedFrom,
|
mergedFromCount: user._count.mergedFrom,
|
||||||
orderCount: user._count.orders,
|
orderCount: user._count.orders,
|
||||||
addressCount: user._count.addresses,
|
addressCount: user._count.addresses,
|
||||||
|
sourcePromo,
|
||||||
_count: undefined,
|
_count: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import { AdminHqLogsController } from './admin-hq-logs.controller';
|
|||||||
import { AdminHqLogsService } from './admin-hq-logs.service';
|
import { AdminHqLogsService } from './admin-hq-logs.service';
|
||||||
import { AdminTicketsController } from './admin-tickets.controller';
|
import { AdminTicketsController } from './admin-tickets.controller';
|
||||||
import { AdminTicketsService } from './admin-tickets.service';
|
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 { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
@@ -79,7 +77,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
|||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
AdminRedeemDebugController,
|
AdminRedeemDebugController,
|
||||||
AdminPromoCodesController,
|
|
||||||
AdminWechatBindingsController,
|
AdminWechatBindingsController,
|
||||||
AdminHqPermissionsController,
|
AdminHqPermissionsController,
|
||||||
],
|
],
|
||||||
@@ -103,7 +100,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
|||||||
AdminXiaofeixiaService,
|
AdminXiaofeixiaService,
|
||||||
AdminProductDetailTemplatesService,
|
AdminProductDetailTemplatesService,
|
||||||
AdminRedeemDebugService,
|
AdminRedeemDebugService,
|
||||||
AdminPromoCodesService,
|
|
||||||
AdminWechatBindingsService,
|
AdminWechatBindingsService,
|
||||||
AdminHqPermissionsService,
|
AdminHqPermissionsService,
|
||||||
SuperAdminGuard,
|
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 { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import {
|
||||||
|
PartnerProxyOrderCreateDto,
|
||||||
|
PartnerProxyOrderPreviewDto,
|
||||||
|
PartnerProxyOrderSendSmsDto,
|
||||||
|
} from './dto/partner-proxy-order.dto';
|
||||||
|
|
||||||
@Controller('trade/orders')
|
@Controller('trade/orders')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -106,3 +111,33 @@ export class PartnerReshipmentController {
|
|||||||
return this.tradeService.listPartnerReshipments(user.actorId);
|
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 { CatalogModule } from '../catalog/catalog.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { CityScopeModule } from '../city-scope/city-scope.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';
|
import { TradeService } from './trade.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
|
imports: [
|
||||||
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
IntegrationsModule,
|
||||||
|
IamModule,
|
||||||
|
CatalogModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
CityScopeModule,
|
||||||
|
PromoModule,
|
||||||
|
forwardRef(() => BenefitModule),
|
||||||
|
CommonModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
TradeController,
|
||||||
|
PartnerOrderController,
|
||||||
|
PartnerProxyOrderController,
|
||||||
|
PartnerReshipmentController,
|
||||||
|
],
|
||||||
providers: [TradeService],
|
providers: [TradeService],
|
||||||
exports: [TradeService],
|
exports: [TradeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ import {
|
|||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
} from '@dukang/domain';
|
} 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 { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { CatalogService } from '../catalog/catalog.service';
|
import { CatalogService } from '../catalog/catalog.service';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.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 { TicketService } from '../common/ticket.service';
|
||||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||||
@@ -41,6 +43,8 @@ export class TradeService {
|
|||||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly promoCodeService: PromoCodeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user