短信验证调试成功

This commit is contained in:
2026-07-06 13:18:56 +08:00
parent 5ba69eb935
commit 1c978b8adc
62 changed files with 2491 additions and 354 deletions
+3 -1
View File
@@ -21,6 +21,7 @@ import ResourcesPage from './pages/ResourcesPage';
import StorePayoutsPage from './pages/StorePayoutsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import TicketsPage from './pages/TicketsPage';
import UserLogsPage from './pages/UserLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
@@ -56,7 +57,8 @@ export default function App() {
<Route path="/store-payouts" element={<StorePayoutsPage />} />
<Route path="/partner-bills" element={<PartnerBillsPage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/third-party-logs" element={<ThirdPartyLogsPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
+11 -2
View File
@@ -13,6 +13,7 @@ import {
SafetyOutlined,
LogoutOutlined,
CloudUploadOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { clearAuth, request, type HqProfile } from '../lib/api';
@@ -57,7 +58,15 @@ const MENU_ITEMS: MenuProps['items'] = [
},
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
{
key: 'logs-group',
icon: <FileTextOutlined />,
label: '日志',
children: [
{ key: '/logs/users', label: '用户日志' },
{ key: '/logs/third-party', label: '第三方日志' },
],
},
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
@@ -86,7 +95,7 @@ export default function AdminLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group']}
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group']}
items={MENU_ITEMS}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
+45
View File
@@ -0,0 +1,45 @@
export type UserLogCategory =
| 'login'
| 'browse_product'
| 'order'
| 'pay'
| 'wechat_auth'
| 'browse_store'
| 'redeem'
| 'profile';
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
browse_store: ['store_list_view', 'store_detail_view'],
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
profile: ['profile_update'],
};
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'browse_product', label: '浏览商品' },
{ value: 'order', label: '下单' },
{ value: 'pay', label: '支付' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
[UserLogCategory, readonly string[]]
>) {
if (events.includes(eventName)) return category;
}
return null;
}
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
) as Record<string, string>;
+217 -40
View File
@@ -1,13 +1,21 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
Table, Tabs, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type ProductDetailContentDto = {
storyTitle?: string;
storyText?: string;
features?: Array<{ icon: string; title: string; desc: string }>;
};
type Row = {
id: string;
skuCode: string;
@@ -21,9 +29,196 @@ type Row = {
status: string;
sortOrder: number;
mainImageUrl?: string | null;
carouselUrls?: string[];
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
createdAt: string;
};
type ProductFormValues = {
skuCode?: string;
barcode69?: string;
name: string;
subtitle?: string;
aromaType?: string;
spec: string;
price: number;
benefitAmount?: number;
status?: string;
sortOrder?: number;
coverUrl?: string;
carouselUrls?: string[];
detailImageUrls?: string[];
storyTitle?: string;
storyText?: string;
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
? detail.features
: [{ icon: 'water_drop', title: '', desc: '' }],
};
}
function buildProductPayload(v: ProductFormValues) {
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const features = (v.features ?? [])
.filter((f) => f?.title?.trim() || f?.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() || '',
desc: f.desc?.trim() || '',
}));
const detailContent: ProductDetailContentDto = {
storyTitle: v.storyTitle?.trim() || undefined,
storyText: v.storyText?.trim() || undefined,
features: features.length ? features : undefined,
};
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
aromaType: v.aromaType,
spec: v.spec,
price: v.price,
benefitAmount: v.benefitAmount,
status: v.status,
sortOrder: v.sortOrder,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
detailContent,
};
}
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
);
}
function ProductDetailFields() {
return (
<>
<Typography.Text type="secondary">CAROUSEL</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">DETAIL</Typography.Text>
<ImageUrlList name="detailImageUrls" label="详情图" bizType="DETAIL" />
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
</Form.Item>
<Form.Item name="storyText" label="故事正文">
<Input.TextArea rows={4} placeholder="商品故事描述" />
</Form.Item>
<Typography.Text type="secondary"></Typography.Text>
<Form.List name="features">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
<Space align="start">
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
<Input placeholder="material icon 名" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="标题" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
)}
</Space>
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
<Input placeholder="简短描述" />
</Form.Item>
</Space>
))}
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</>
);
}
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
return (
<>
{mode === 'create' && (
<>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</>
)}
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input />
</Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
<Input placeholder="500ml | 52度" />
</Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="benefitAmount" label="权益额">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</>
);
}
export default function ProductsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
@@ -60,10 +255,7 @@ export default function ProductsPage() {
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
});
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}></Button>
),
@@ -88,11 +280,12 @@ export default function ProductsPage() {
</Form>
<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); } }} />
<Drawer title="编辑商品" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
const payload = buildProductPayload(v);
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
@@ -106,48 +299,32 @@ export default function ProductsPage() {
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
]} />
</Form>
</>
)}
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/products', { method: 'POST', body: JSON.stringify(v) });
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={520}>
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input placeholder="500ml | 52度" /></Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
]} />
</Form>
</Modal>
</div>
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
userId: string | null;
userNo: string | null;
phone: string | null;
nickname: string | null;
category: UserLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
export default function UserLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
userId: searchParams.get('userId') ?? '',
phone: searchParams.get('phone') ?? '',
userNo: searchParams.get('userNo') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/logs/users',
() => {
const qs = new URLSearchParams();
if (filters.userId) qs.set('userId', filters.userId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.userNo) qs.set('userNo', filters.userNo);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '用户', width: 160,
render: (_, r) => (
<div>
<div>{r.nickname || r.userNo || '—'}</div>
<div style={{ color: '#999', fontSize: 12 }}>{r.phone || r.userId || '—'}</div>
</div>
),
},
{
title: '分类', dataIndex: 'category', width: 100,
render: (v: UserLogCategory | null, r) => (
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 160 },
{
title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要', ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/logs/users/${row.id}`));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Segmented
style={{ marginBottom: 16 }}
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
const next = new URLSearchParams(searchParams);
if (v) next.set('category', String(v));
else next.delete('category');
setSearchParams(next);
}}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
setFilters(v);
setPage(1);
const next = new URLSearchParams(searchParams);
for (const key of ['userId', 'phone', 'userNo', 'eventName'] as const) {
if (v[key]) next.set(key, v[key]);
else next.delete(key);
}
setSearchParams(next);
}}>
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
<Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
</pre>
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+18 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -24,6 +25,7 @@ type UserDetail = AdminUserRow & {
};
export default function UsersPage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
@@ -99,11 +101,16 @@ export default function UsersPage() {
},
{
title: '操作',
width: 80,
width: 140,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
<Space size="small">
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
<Button type="link" size="small" onClick={() => navigate(`/logs/users?userId=${row.id}`)}>
</Button>
</Space>
),
},
];
@@ -198,6 +205,13 @@ export default function UsersPage() {
/>
</>
)}
<Button
type="primary"
style={{ marginTop: 16 }}
onClick={() => navigate(`/logs/users?userId=${detail.id}`)}
>
</Button>
</>
)}
</Drawer>
+2
View File
@@ -17,6 +17,7 @@ import RedeemPage from './pages/RedeemPage';
import RedeemCodePage from './pages/RedeemCodePage';
import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage';
import CustomerServicePage from './pages/CustomerServicePage';
import { UserSessionProvider } from './contexts/UserSessionContext';
export default function App() {
@@ -34,6 +35,7 @@ export default function App() {
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/order/confirm" element={<OrderConfirmPage />} />
<Route path="/pay" element={<PayPage />} />
<Route path="/customer-service" element={<CustomerServicePage />} />
<Route path="/addresses" element={<AddressListPage />} />
<Route path="/addresses/new" element={<AddressEditPage />} />
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
@@ -0,0 +1,70 @@
import { useNavigate } from 'react-router-dom';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import { track } from '../lib/analytics';
type ContactCustomerSheetProps = {
orderId?: string;
orderNo?: string;
onClose: () => void;
};
export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
const navigate = useNavigate();
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function openPhone() {
track('cs_contact', { type: 'phone', orderId });
window.location.href = `tel:${tel}`;
onClose();
}
function openChat() {
track('cs_contact', { type: 'chat', orderId });
const qs = new URLSearchParams();
if (orderId) qs.set('orderId', orderId);
if (orderNo) qs.set('orderNo', orderNo);
onClose();
navigate(`/customer-service?${qs.toString()}`);
}
return (
<div className="contact-customer-overlay" onClick={onClose}>
<div className="contact-customer-sheet" onClick={(e) => e.stopPropagation()}>
<div className="contact-customer-head">
<h3></h3>
<button type="button" className="contact-customer-close" aria-label="关闭" onClick={onClose}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="contact-customer-options">
<button type="button" className="contact-customer-option" onClick={openPhone}>
<div className="contact-customer-option-icon">
<span className="material-symbols-outlined">call</span>
</div>
<div className="contact-customer-option-body">
<p className="contact-customer-option-title"></p>
<p className="contact-customer-option-sub">{CUSTOMER_SERVICE_PHONE}</p>
</div>
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button>
<button type="button" className="contact-customer-option" onClick={openChat}>
<div className="contact-customer-option-icon">
<span className="material-symbols-outlined">chat</span>
</div>
<div className="contact-customer-option-body">
<p className="contact-customer-option-title">线</p>
<p className="contact-customer-option-sub"></p>
</div>
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button>
</div>
<button type="button" className="contact-customer-cancel" onClick={onClose}>
</button>
</div>
</div>
);
}
@@ -1,6 +1,9 @@
import { useEffect, useState } from 'react';
import { bindPhone, request, saveSession } from '../lib/api';
import { SmsScene } from '@dukang/shared-types';
import { bindPhone, type SessionPayload } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { useSmsCode } from '../lib/use-sms-code';
import { useUserSession } from '../contexts/UserSessionContext';
type PhoneVerifySheetProps = {
open: boolean;
@@ -16,18 +19,18 @@ export default function PhoneVerifySheet({
onClose,
onSuccess,
}: PhoneVerifySheetProps) {
const { applySession } = useUserSession();
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode();
useEffect(() => {
if (!open) {
setPhone('');
setCode('');
setMsg('');
setCodeCooldown(0);
setError('');
clearMessages();
return;
}
if (defaultPhone) {
@@ -36,51 +39,32 @@ export default function PhoneVerifySheet({
setPhone(normalized);
}
}
}, [open, defaultPhone]);
}, [open, defaultPhone, clearMessages, setError]);
async function sendCode() {
const phoneCheck = validateMobilePhone(phone);
if (!phoneCheck.ok) {
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
return;
}
setMsg('');
await request('USER_H5', '/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'BIND_PHONE' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
async function onSendCode() {
clearMessages();
await sendCode(phone, SmsScene.BIND_PHONE);
}
async function submit() {
const phoneCheck = validateMobilePhone(phone);
if (!phoneCheck.ok) {
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
setError(phoneCheck.message ?? '请输入正确的手机号码');
return;
}
if (!code.trim()) {
setMsg('请输入验证码');
setError('请输入验证码');
return;
}
setLoading(true);
setMsg('');
setError('');
try {
const session = await bindPhone(phone, code);
saveSession(session);
applySession(session as SessionPayload);
onSuccess();
onClose();
} catch (e) {
setMsg(e instanceof Error ? e.message : '验证失败');
setError(e instanceof Error ? e.message : '验证失败');
} finally {
setLoading(false);
}
@@ -105,7 +89,8 @@ export default function PhoneVerifySheet({
value={phone}
onChange={(e) => {
setPhone(normalizePhoneInput(e.target.value));
setMsg('');
setError('');
clearMessages();
}}
/>
</div>
@@ -115,19 +100,26 @@ export default function PhoneVerifySheet({
inputMode="numeric"
className="login-field-input"
placeholder="请输入验证码"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<button
type="button"
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
disabled={codeCooldown > 0}
onClick={sendCode}
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
disabled={codeCooldown > 0 || sending}
onClick={onSendCode}
>
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
{sending
? '发送中...'
: codeCooldown > 0
? `${codeCooldown}s 后重新获取`
: '获取验证码'}
</button>
</div>
{msg && <p className="login-msg">{msg}</p>}
{(error || sentHint) && (
<p className={`login-msg${sentHint && !error ? ' login-msg--hint' : ''}`}>{error || sentHint}</p>
)}
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
{loading ? '验证中...' : '确认验证'}
</button>
@@ -13,6 +13,7 @@ import {
ensureSession,
getDeviceKey,
request,
saveSession,
type SessionPayload,
type UserProfile,
} from '../lib/api';
@@ -21,6 +22,7 @@ type UserSessionContextValue = {
ready: boolean;
profile: UserProfile | null;
phoneVerified: boolean;
applySession: (session: SessionPayload) => void;
refreshProfile: () => Promise<void>;
resetSession: () => Promise<void>;
};
@@ -33,6 +35,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
const [phoneVerified, setPhoneVerified] = useState(false);
const applySession = useCallback((session: SessionPayload) => {
saveSession(session);
if (session.user) setProfile(session.user);
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
}, []);
@@ -83,10 +86,11 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
ready,
profile,
phoneVerified,
applySession,
refreshProfile,
resetSession,
}),
[ready, profile, phoneVerified, refreshProfile, resetSession],
[ready, profile, phoneVerified, applySession, refreshProfile, resetSession],
);
if (!ready) {
+16
View File
@@ -0,0 +1,16 @@
import { apiBase } from './api';
export function track(eventName: string, params?: Record<string, unknown>) {
const token = localStorage.getItem('accessToken');
if (!token) return;
void fetch(`${apiBase}/analytics/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Client-App': 'USER_H5',
},
body: JSON.stringify({ events: [{ eventName, params }] }),
}).catch(() => {});
}
+81
View File
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { SmsScene } from '@dukang/shared-types';
import { request } from './api';
import { fetchClientConfig } from './pay-wechat';
import { validateMobilePhone } from './phone';
export function useSmsCode() {
const [codeCooldown, setCodeCooldown] = useState(0);
const [sending, setSending] = useState(false);
const [sentHint, setSentHint] = useState('');
const [error, setError] = useState('');
const [mockSms, setMockSms] = useState(true);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
fetchClientConfig()
.then((cfg) => setMockSms(cfg.mockSms))
.catch(() => {});
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
const startCooldown = useCallback(() => {
setCodeCooldown(60);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return 0;
}
return c - 1;
});
}, 1000);
}, []);
const sendCode = useCallback(
async (phone: string, scene: SmsScene) => {
const phoneCheck = validateMobilePhone(phone);
if (!phoneCheck.ok) {
setError(phoneCheck.message ?? '请输入正确的手机号码');
return false;
}
setSending(true);
setError('');
setSentHint('');
try {
await request('USER_H5', '/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene }),
});
setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收');
startCooldown();
return true;
} catch (e) {
setError(e instanceof Error ? e.message : '发送失败');
return false;
} finally {
setSending(false);
}
},
[mockSms, startCooldown],
);
const clearMessages = useCallback(() => {
setError('');
setSentHint('');
}, []);
return {
sendCode,
sending,
codeCooldown,
sentHint,
error,
setError,
clearMessages,
mockSms,
};
}
+4 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { useUserSession } from '../contexts/UserSessionContext';
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
type Address = {
@@ -24,6 +25,7 @@ function formatAddress(a: Address) {
}
export default function AddressListPage() {
const { profile } = useUserSession();
const [list, setList] = useState<Address[]>([]);
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
@@ -42,7 +44,7 @@ export default function AddressListPage() {
useEffect(() => {
loadList();
}, [loadList]);
}, [loadList, profile?.id]);
function selectAddress(addr: Address) {
if (!selectMode) return;
@@ -137,7 +139,7 @@ export default function AddressListPage() {
<div className="address-list-cards">
{list.map((a) => {
const isDefault = a.isDefault === 1;
const isDefault = Number(a.isDefault) === 1;
const isSelected = selectMode && currentAddressId === String(a.id);
return (
<article
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
type ChatMessage = {
id: string;
role: 'user' | 'agent' | 'system';
text: string;
time?: string;
};
const QUICK_QUESTIONS = [
{ key: 'logistics', label: '物流查询' },
{ key: 'damage', label: '破损补发' },
{ key: 'refund', label: '申请退款' },
{ key: 'address', label: '修改地址' },
] as const;
function nowLabel() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function agentReply(userText: string, orderNo?: string) {
if (/订单|DK\d+/i.test(userText) || orderNo) {
return '已收到您的订单信息,客服将在工作时间 9:00-18:00 内为您处理,请保持电话畅通。';
}
if (userText.includes('破损') || userText.includes('补发')) {
return '非常抱歉给您带来不便。请提供订单号并描述破损情况,我们将尽快安排补发。';
}
if (userText.includes('退款')) {
return '请提供订单号与退款原因,客服将为您核实订单状态并协助办理。';
}
if (userText.includes('地址')) {
return '待发货/出库中的订单可在订单详情修改收货地址;已发货订单请联系客服协助处理。';
}
if (userText.includes('物流')) {
return '您可在订单详情查看配送进度;如有异常请提供订单号,我们为您查询。';
}
return '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
}
export default function CustomerServicePage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
const orderNo = params.get('orderNo') || '';
const [input, setInput] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sending, setSending] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const welcome: ChatMessage[] = [
{ id: 'sys-1', role: 'system', text: nowLabel(), time: nowLabel() },
{
id: 'agent-welcome',
role: 'agent',
text: orderNo
? `您好,我是杜康好客客服。已为您关联订单 ${orderNo},请问有什么可以帮您?`
: '您好,我是杜康好客客服。请问有什么可以帮您?',
},
];
setMessages(welcome);
}, [orderNo]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
function pushMessage(role: ChatMessage['role'], text: string) {
setMessages((prev) => [...prev, { id: `${Date.now()}-${prev.length}`, role, text }]);
}
async function sendText(text: string) {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
pushMessage('user', trimmed);
setInput('');
window.setTimeout(() => {
pushMessage('agent', agentReply(trimmed, orderNo));
setSending(false);
}, 600);
}
async function loadOrderContext() {
if (!orderId) return null;
try {
return await request<Record<string, unknown>>('USER_H5', `/trade/orders/${orderId}`);
} catch {
return null;
}
}
useEffect(() => {
if (!orderId) return;
loadOrderContext().then((order) => {
if (!order) return;
const no = String(order.orderNo || orderNo);
if (no && !orderNo) {
pushMessage('system', `已关联订单 ${no}`);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderId]);
return (
<div className="customer-service-page">
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
{orderNo && (
<div className="customer-service-order-card">
<span className="material-symbols-outlined">receipt_long</span>
<div>
<p className="customer-service-order-label"></p>
<p className="customer-service-order-no">{orderNo}</p>
</div>
</div>
)}
<div className="customer-service-chat" ref={listRef}>
{messages.map((m) => {
if (m.role === 'system') {
return (
<div key={m.id} className="customer-service-time">
<span>{m.text}</span>
</div>
);
}
const isUser = m.role === 'user';
return (
<div
key={m.id}
className={`customer-service-bubble-row${isUser ? ' is-user' : ' is-agent'}`}
>
{!isUser && (
<div className="customer-service-avatar" aria-hidden>
<span className="material-symbols-outlined">support_agent</span>
</div>
)}
<div className={`customer-service-bubble${isUser ? ' is-user' : ''}`}>{m.text}</div>
</div>
);
})}
</div>
<div className="customer-service-quick">
{QUICK_QUESTIONS.map((q) => (
<button
key={q.key}
type="button"
className="customer-service-quick-btn"
disabled={sending}
onClick={() => sendText(q.label)}
>
{q.label}
</button>
))}
</div>
<footer className="customer-service-inputbar">
<input
type="text"
className="customer-service-input"
placeholder="请输入您的问题..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void sendText(input);
}}
/>
<button
type="button"
className="customer-service-send"
disabled={sending || !input.trim()}
onClick={() => sendText(input)}
>
</button>
</footer>
</div>
);
}
+5
View File
@@ -5,6 +5,7 @@ import ProductCarousel from '../components/ProductCarousel';
import TabMainHeader from '../components/TabMainHeader';
import AppToast from '../components/AppToast';
import { getProductImages } from '../lib/product-images';
import { track } from '../lib/analytics';
import CouponBadge from '@dukang/shared-ui/CouponBadge';
type Product = {
@@ -42,6 +43,10 @@ export default function HomePage() {
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
const [toast, setToast] = useState('');
useEffect(() => {
track('home_view', { pagePath: '/' });
}, []);
useEffect(() => {
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
setCities(list);
+35 -39
View File
@@ -2,22 +2,27 @@ import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types';
import { request, saveSession, type UserProfile } from '../lib/api';
import { SmsScene } from '@dukang/shared-types';
import { request, type SessionPayload } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { useSmsCode } from '../lib/use-sms-code';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { useUserSession } from '../contexts/UserSessionContext';
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get('return') || '/';
const [phone, setPhone] = useState('13800000001');
const [code, setCode] = useState('123456');
const { applySession } = useUserSession();
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [agreed, setAgreed] = useState(true);
const [codeCooldown, setCodeCooldown] = useState(0);
const [msg, setMsg] = useState('');
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false);
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
useSmsCode();
useEffect(() => {
if (!isWechatEnv()) return;
@@ -38,12 +43,12 @@ export default function LoginPage() {
return;
}
if (result.accessToken) {
saveSession({
applySession({
accessToken: result.accessToken,
refreshToken: result.refreshToken ?? '',
deviceKey: result.deviceKey,
phoneVerified: !!result.phoneVerified,
user: result.user as never,
user: result.user as SessionPayload['user'],
});
navigate(returnTo.startsWith('/') ? returnTo : '/');
}
@@ -57,29 +62,11 @@ export default function LoginPage() {
return true;
}
async function sendCode() {
async function onSendCode() {
if (!ensureAgreed()) return;
const phoneCheck = validateMobilePhone(phone);
if (!phoneCheck.ok) {
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
return;
}
clearMessages();
setMsg('');
await request('USER_H5', '/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN);
}
async function login() {
@@ -95,6 +82,7 @@ export default function LoginPage() {
}
setLoading(true);
setMsg('');
setSmsError('');
try {
if (bindMode && wxSessionKey) {
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
@@ -104,15 +92,11 @@ export default function LoginPage() {
handleWechatLoginResult(data);
return;
}
const data = await request<{
accessToken: string;
refreshToken: string;
deviceKey?: string;
}>('USER_H5', '/auth/login/sms', {
const data = await request<SessionPayload>('USER_H5', '/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveSession(data);
applySession(data);
navigate(returnTo.startsWith('/') ? returnTo : '/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
@@ -136,6 +120,8 @@ export default function LoginPage() {
}
}
const displayMsg = msg || smsError;
return (
<div className="login-page">
<header className="login-header">
@@ -164,6 +150,7 @@ export default function LoginPage() {
onChange={(e) => {
setPhone(normalizePhoneInput(e.target.value));
setMsg('');
clearMessages();
}}
/>
</div>
@@ -173,19 +160,28 @@ export default function LoginPage() {
inputMode="numeric"
className="login-field-input"
placeholder="请输入验证码"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<button
type="button"
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
disabled={codeCooldown > 0}
onClick={sendCode}
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
disabled={codeCooldown > 0 || sending}
onClick={onSendCode}
>
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
{sending
? '发送中...'
: codeCooldown > 0
? `${codeCooldown}s 后重新获取`
: '获取验证码'}
</button>
</div>
{msg && <p className="login-msg">{msg}</p>}
{(displayMsg || sentHint) && (
<p className={`login-msg${sentHint && !displayMsg ? ' login-msg--hint' : ''}`}>
{displayMsg || sentHint}
</p>
)}
<button
type="button"
className="login-sms-btn"
+5 -1
View File
@@ -4,6 +4,7 @@ import TabMainHeader from '../components/TabMainHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { useUserSession } from '../contexts/UserSessionContext';
import ContactCustomerSheet from '../components/ContactCustomerSheet';
const DEFAULT_AVATAR =
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
@@ -35,6 +36,7 @@ export default function MinePage() {
const [benefitBalance, setBenefitBalance] = useState(0);
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [toast, setToast] = useState('');
const [showCs, setShowCs] = useState(false);
useEffect(() => {
Promise.all([
@@ -67,7 +69,7 @@ export default function MinePage() {
function handleService(item: (typeof SERVICES)[number]) {
if ('to' in item && item.to) return;
if (item.action === 'cs') showToast('preV1:在线客服即将开放');
if (item.action === 'cs') setShowCs(true);
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
}
@@ -208,6 +210,8 @@ export default function MinePage() {
</main>
{toast && <div className="mine-toast">{toast}</div>}
{showCs && <ContactCustomerSheet onClose={() => setShowCs(false)} />}
</div>
);
}
+14 -1
View File
@@ -5,8 +5,10 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation';
import { getProductMainImage } from '../lib/product-images';
import { track } from '../lib/analytics';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { useUserSession } from '../contexts/UserSessionContext';
import { tryGetClientGpsLocation } from '../lib/client-location';
type Address = {
id: string;
@@ -76,6 +78,12 @@ export default function OrderConfirmPage() {
});
}, [params]);
useEffect(() => {
if (productId) {
track('order_confirm_view', { refType: 'PRODUCT', refId: productId, productId, quantity });
}
}, [productId, quantity]);
useEffect(() => {
if (!productId || !addressId) return;
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
@@ -116,7 +124,12 @@ export default function OrderConfirmPage() {
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
async function doSubmit() {
const clientLocation = await tryGetClientGpsLocation();
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
method: 'POST',
body: JSON.stringify({
+21 -13
View File
@@ -4,6 +4,7 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { buildOrderAddressSelectUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import ContactCustomerSheet from '../components/ContactCustomerSheet';
type OrderItem = {
productName: string;
@@ -145,7 +146,11 @@ export default function OrderDetailPage() {
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
const canRefund = order && ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
const canPay = order?.status === 'PENDING_PAY' && !isReship;
const canRefund =
order &&
!canPay &&
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
const freightTotal = Number(order?.freightAmount ?? 0);
@@ -402,6 +407,16 @@ export default function OrderDetailPage() {
退
</button>
)}
{canPay && (
<button
type="button"
className="order-detail-action-primary"
onClick={() => navigate(`/pay?orderId=${order.id}`)}
>
<span className="material-symbols-outlined">payments</span>
¥{formatMoney(Number(order.payAmount))}
</button>
)}
{canConfirmReceive && (
<button
type="button"
@@ -416,18 +431,11 @@ export default function OrderDetailPage() {
</footer>
{showCs && (
<div className="modal-overlay" onClick={() => setShowCs(false)}>
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
<div className="modal-grabber" />
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<p className="text-variant body-md" style={{ marginBottom: 16 }}>
preV1 Mock 9:00-18:00 {order.orderNo}
</p>
<button type="button" className="btn btn-primary btn-block" onClick={() => setShowCs(false)}>
</button>
</div>
</div>
<ContactCustomerSheet
orderId={order.id}
orderNo={order.orderNo}
onClose={() => setShowCs(false)}
/>
)}
</div>
);
+11 -1
View File
@@ -42,6 +42,7 @@ export default function OrderListPage() {
const items = (o.items as Array<Record<string, unknown>>) || [];
const item = items[0];
const isReshipDemo = i === 0 && tab === 'all';
const isPendingPay = String(o.status) === 'PENDING_PAY';
return (
<div key={String(o.id)} className="card">
<div className="card-row" style={{ marginBottom: 8 }}>
@@ -63,7 +64,16 @@ export default function OrderListPage() {
<div className="amount-lg">¥{Number(o.payAmount)}</div>
</div>
)}
<div style={{ textAlign: 'right', marginTop: 12 }}>
<div style={{ textAlign: 'right', marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{isPendingPay && (
<button
type="button"
className="btn btn-primary btn-pill"
onClick={() => navigate(`/pay?orderId=${o.id}`)}
>
</button>
)}
<Link
to={`/orders/${o.id}${isReshipDemo ? '?type=reship' : ''}`}
className="btn btn-outline btn-pill"
+34 -28
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import type { ProductDetailContentDto } from '@dukang/shared-types';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import AppImage from '@dukang/shared-ui/AppImage';
import ProductCarousel from '../components/ProductCarousel';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
import type { ProductImageSource } from '../lib/product-images';
@@ -11,13 +13,9 @@ type Product = ProductImageSource & {
subtitle?: string;
price: number;
benefitAmount?: number;
detailContent?: ProductDetailContentDto | null;
};
const FEATURES = [
{ icon: 'water_drop', title: '泉水酿造', desc: '甘冽清甜 灵动自然' },
{ icon: 'grain', title: '精选五谷', desc: '传统比例 匠心发酵' },
] as const;
export default function ProductDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
@@ -28,6 +26,12 @@ export default function ProductDetailPage() {
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
}, [id]);
useEffect(() => {
if (id) {
track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id });
}
}, [id]);
useEffect(() => {
function onScroll() {
setHeaderSolid(window.scrollY > 100);
@@ -41,6 +45,8 @@ export default function ProductDetailPage() {
const benefit = Number(product.benefitAmount ?? product.price);
const carouselImages = getProductCarouselImages(product);
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
const features = detail.features ?? [];
return (
<div className="product-detail-page">
@@ -107,31 +113,31 @@ export default function ProductDetailPage() {
<h3></h3>
</div>
{detailImages[0] && (
<AppImage src={detailImages[0]} alt="" wrapperClassName="product-detail-banner" />
)}
{detailImages.map((src, index) => (
<AppImage key={`${src}-${index}`} src={src} alt="" wrapperClassName="product-detail-banner" />
))}
<div className="product-detail-copy">
<div className="product-detail-story">
<h4> · </h4>
<p>
</p>
</div>
<div className="product-detail-features">
{FEATURES.map((f) => (
<div key={f.title} className="product-detail-feature">
<span className="material-symbols-outlined">{f.icon}</span>
<div className="product-detail-feature-title">{f.title}</div>
<div className="product-detail-feature-desc">{f.desc}</div>
{(detail.storyTitle || detail.storyText || features.length > 0) && (
<div className="product-detail-copy">
{(detail.storyTitle || detail.storyText) && (
<div className="product-detail-story">
{detail.storyTitle && <h4>{detail.storyTitle}</h4>}
{detail.storyText && <p>{detail.storyText}</p>}
</div>
))}
</div>
</div>
)}
{detailImages.length > 1 && (
<AppImage src={detailImages[1]} alt="" wrapperClassName="product-detail-banner" />
{features.length > 0 && (
<div className="product-detail-features">
{features.map((f) => (
<div key={`${f.title}-${f.icon}`} className="product-detail-feature">
<span className="material-symbols-outlined">{f.icon}</span>
<div className="product-detail-feature-title">{f.title}</div>
<div className="product-detail-feature-desc">{f.desc}</div>
</div>
))}
</div>
)}
</div>
)}
</section>
</main>
+5 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import ProductCarousel from '../components/ProductCarousel';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
@@ -55,7 +56,10 @@ export default function StoreDetailPage() {
const [headerSolid, setHeaderSolid] = useState(false);
useEffect(() => {
if (id) request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
if (id) {
request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
track('store_detail_view', { refType: 'STORE', refId: id, storeId: id });
}
}, [id]);
useEffect(() => {
+5
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import TabMainHeader from '../components/TabMainHeader';
import RegionPicker from '../components/RegionPicker';
import {
@@ -64,6 +65,10 @@ export default function StoreListPage() {
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
useEffect(() => {
track('store_list_view', { pagePath: '/stores' });
}, []);
useEffect(() => {
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
}, []);
+251
View File
@@ -5541,3 +5541,254 @@
line-height: 1.5;
}
.login-msg--hint {
color: var(--color-success, #2d6a4f);
}
/* Contact customer sheet */
.contact-customer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.contact-customer-sheet {
background: #faf9f7;
border-radius: 16px 16px 0 0;
padding: 20px 20px calc(16px + env(safe-area-inset-bottom));
max-width: 480px;
width: 100%;
margin: 0 auto;
}
.contact-customer-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.contact-customer-head h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1c1b;
}
.contact-customer-close {
border: none;
background: transparent;
padding: 4px;
color: #5a413f;
}
.contact-customer-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.contact-customer-option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px;
border: 1px solid rgba(226, 190, 188, 0.3);
border-radius: 12px;
background: #f4f3f1;
text-align: left;
}
.contact-customer-option-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.1);
color: #a61d24;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.contact-customer-option-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1a1c1b;
}
.contact-customer-option-sub {
margin: 4px 0 0;
font-size: 12px;
color: #5a413f;
}
.contact-customer-chevron {
margin-left: auto;
color: #5a413f;
}
.contact-customer-cancel {
width: 100%;
padding: 16px;
border: none;
border-radius: 12px;
background: #e3e2e0;
font-size: 16px;
font-weight: 600;
color: #1a1c1b;
}
/* Customer service chat page */
.customer-service-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #faf9f7;
}
.customer-service-order-card {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 16px 0;
padding: 12px 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
color: #a61d24;
}
.customer-service-order-label {
margin: 0;
font-size: 12px;
color: #5a413f;
}
.customer-service-order-no {
margin: 4px 0 0;
font-size: 14px;
font-weight: 600;
color: #1a1c1b;
}
.customer-service-chat {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.customer-service-time {
display: flex;
justify-content: center;
}
.customer-service-time span {
font-size: 12px;
color: #999;
background: rgba(227, 226, 224, 0.3);
padding: 4px 12px;
border-radius: 999px;
}
.customer-service-bubble-row {
display: flex;
gap: 8px;
max-width: 85%;
}
.customer-service-bubble-row.is-user {
align-self: flex-end;
}
.customer-service-bubble-row.is-agent {
align-self: flex-start;
}
.customer-service-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
border: 1px solid rgba(226, 190, 188, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #a61d24;
flex-shrink: 0;
}
.customer-service-bubble {
padding: 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
background: #fff;
color: #1a1c1b;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
}
.customer-service-bubble.is-user {
background: #a61d24;
color: #fff;
box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
}
.customer-service-quick {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px 12px;
}
.customer-service-quick-btn {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(166, 29, 36, 0.25);
background: #fff;
color: #a61d24;
font-size: 12px;
}
.customer-service-inputbar {
display: flex;
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #e3e2e0;
}
.customer-service-input {
flex: 1;
border: 1px solid #e3e2e0;
border-radius: 999px;
padding: 10px 16px;
font-size: 14px;
outline: none;
}
.customer-service-send {
border: none;
border-radius: 999px;
padding: 10px 18px;
background: #a61d24;
color: #fff;
font-size: 14px;
font-weight: 600;
}
.customer-service-send:disabled {
opacity: 0.5;
}
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
resolve: {
alias: {
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
'@dukang/shared-types': path.resolve(__dirname, '../../packages/shared-types/src'),
},
},
server: {
+12
View File
@@ -4,6 +4,18 @@
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts",
"default": "./dist/index.js"
},
"./user-log": {
"types": "./dist/user-log.d.ts",
"import": "./src/user-log.ts",
"default": "./dist/user-log.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
+15
View File
@@ -22,6 +22,21 @@ export interface ProductDto {
carouselUrls?: string[];
/** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
}
export interface ProductDetailFeatureDto {
icon: string;
title: string;
desc: string;
}
export interface ProductDetailContentDto {
storyTitle?: string;
storyText?: string;
features?: ProductDetailFeatureDto[];
/** 兜底详情图,优先 DETAIL 资源 */
images?: string[];
}
export interface ProductListQuery {
+11
View File
@@ -10,8 +10,15 @@ export interface AppConfig {
wxMchId: string;
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
ossEnabled: boolean;
aliyunSmsSignName: string;
aliyunSmsTemplateCode: string;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
}
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
const e =
env ??
@@ -28,5 +35,9 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
wxAppId: e.WX_APP_ID ?? '',
wxMchId: e.WX_MCH_ID ?? '',
ossEnabled: e.OSS_ENABLED === 'true',
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
};
}
+1
View File
@@ -9,3 +9,4 @@ export * from './redeem';
export * from './settlement';
export * from './ops';
export * from './ticket';
export * from './user-log';
+61
View File
@@ -0,0 +1,61 @@
export type UserLogCategory =
| 'login'
| 'browse_product'
| 'order'
| 'pay'
| 'wechat_auth'
| 'browse_store'
| 'redeem'
| 'profile';
export const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
browse_store: ['store_list_view', 'store_detail_view'],
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
profile: ['profile_update'],
};
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'browse_product', label: '浏览商品' },
{ value: 'order', label: '下单' },
{ value: 'pay', label: '支付' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
[UserLogCategory, readonly string[]]
>) {
if (events.includes(eventName)) return category;
}
return null;
}
export function eventNamesForUserLogCategory(category: string): string[] | undefined {
if (!category) return undefined;
return [...(USER_LOG_EVENT_CATEGORIES[category as UserLogCategory] ?? [])];
}
export interface UserLogRowDto {
id: string;
userId: string | null;
userNo: string | null;
phone: string | null;
nickname: string | null;
category: UserLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
}
+1
View File
@@ -27,6 +27,7 @@ export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
export type ClientRuntimeConfig = {
mockPay: boolean;
wechatPayEnabled: boolean;
mockSms: boolean;
};
export interface WechatLoginResult {
+305 -23
View File
@@ -31,7 +31,7 @@ importers:
version: link:../../packages/shared-types
antd:
specifier: ^5.22.0
version: 5.29.3(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
dayjs:
specifier: ^1.11.13
version: 1.11.21
@@ -56,13 +56,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
version: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
apps/h5-partner:
dependencies:
@@ -96,13 +96,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
version: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
apps/h5-shop:
dependencies:
@@ -133,13 +133,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
version: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
apps/h5-user:
dependencies:
@@ -173,13 +173,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
version: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
packages/domain:
devDependencies:
@@ -188,7 +188,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^1.6.0
version: 1.6.1(@types/node@20.19.43)(terser@5.48.0)
version: 1.6.1(@types/node@22.20.0)(terser@5.48.0)
packages/shared-types:
devDependencies:
@@ -220,6 +220,12 @@ importers:
server/dukang-api:
dependencies:
'@alicloud/dysmsapi20170525':
specifier: ^4.6.0
version: 4.6.0
'@alicloud/openapi-client':
specifier: ^0.4.15
version: 0.4.15
'@dukang/domain':
specifier: workspace:*
version: link:../../packages/domain
@@ -259,6 +265,9 @@ importers:
class-validator:
specifier: ^0.14.1
version: 0.14.4
dotenv:
specifier: ^17.4.2
version: 17.4.2
express:
specifier: ^4.21.0
version: 4.22.1
@@ -305,6 +314,60 @@ importers:
packages:
'@alicloud/credentials@2.4.5':
resolution: {integrity: sha512-od1ufCxOO7cP2R4EVFliOB0kGo9lUXCibyj/mzmI6yLhxeqhqsegTzVsx5p2NJJsceKJnYcmye7FWKyLJAFBkw==}
'@alicloud/darabonba-array@0.1.2':
resolution: {integrity: sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==}
'@alicloud/darabonba-encode-util@0.0.1':
resolution: {integrity: sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==}
'@alicloud/darabonba-encode-util@0.0.2':
resolution: {integrity: sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==}
'@alicloud/darabonba-map@0.0.1':
resolution: {integrity: sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==}
'@alicloud/darabonba-signature-util@0.0.4':
resolution: {integrity: sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==}
'@alicloud/darabonba-string@1.0.3':
resolution: {integrity: sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==}
'@alicloud/dysmsapi20170525@4.6.0':
resolution: {integrity: sha512-cUAgK/nHEgG8TVOlvMDAEINJjPo9D/l/x9loCom7CqlE8U1uFeZh7Ko2LR2ksPoHbTzzx9NYbXQthFoWgdlThQ==}
'@alicloud/endpoint-util@0.0.1':
resolution: {integrity: sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==}
'@alicloud/gateway-pop@0.0.6':
resolution: {integrity: sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==}
'@alicloud/gateway-spi@0.0.8':
resolution: {integrity: sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==}
'@alicloud/openapi-client@0.4.15':
resolution: {integrity: sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==}
'@alicloud/openapi-core@1.0.7':
resolution: {integrity: sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==}
'@alicloud/openapi-util@0.3.3':
resolution: {integrity: sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==}
'@alicloud/tea-typescript@1.8.0':
resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==}
'@alicloud/tea-util@1.4.11':
resolution: {integrity: sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==}
'@alicloud/tea-util@1.4.9':
resolution: {integrity: sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==}
'@alicloud/tea-xml@0.0.3':
resolution: {integrity: sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==}
'@angular-devkit/core@17.3.11':
resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==}
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -455,6 +518,9 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@darabonba/typescript@1.0.4':
resolution: {integrity: sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==}
'@emotion/hash@0.8.0':
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
@@ -1115,9 +1181,15 @@ packages:
'@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -1147,6 +1219,9 @@ packages:
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/xml2js@0.4.14':
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
'@typescript-eslint/eslint-plugin@8.62.1':
resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1733,6 +1808,10 @@ packages:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -2068,6 +2147,9 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
httpx@2.3.3:
resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==}
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
@@ -2105,6 +2187,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inquirer@8.2.6:
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
engines: {node: '>=12.0.0'}
@@ -2261,6 +2346,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kitx@2.2.0:
resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2423,6 +2511,12 @@ packages:
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
moment-timezone@0.5.48:
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -3087,6 +3181,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sm3@1.0.3:
resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3590,6 +3687,146 @@ packages:
snapshots:
'@alicloud/credentials@2.4.5':
dependencies:
'@alicloud/tea-typescript': 1.8.0
httpx: 2.3.3
ini: 1.3.8
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-array@0.1.2':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-encode-util@0.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
moment: 2.30.1
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-encode-util@0.0.2':
dependencies:
moment: 2.30.1
'@alicloud/darabonba-map@0.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-signature-util@0.0.4':
dependencies:
'@alicloud/darabonba-encode-util': 0.0.1
transitivePeerDependencies:
- supports-color
'@alicloud/darabonba-string@1.0.3':
dependencies:
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/dysmsapi20170525@4.6.0':
dependencies:
'@alicloud/openapi-core': 1.0.7
'@darabonba/typescript': 1.0.4
transitivePeerDependencies:
- supports-color
'@alicloud/endpoint-util@0.0.1':
dependencies:
'@alicloud/tea-typescript': 1.8.0
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/gateway-pop@0.0.6':
dependencies:
'@alicloud/credentials': 2.4.5
'@alicloud/darabonba-array': 0.1.2
'@alicloud/darabonba-encode-util': 0.0.2
'@alicloud/darabonba-map': 0.0.1
'@alicloud/darabonba-signature-util': 0.0.4
'@alicloud/darabonba-string': 1.0.3
'@alicloud/endpoint-util': 0.0.1
'@alicloud/gateway-spi': 0.0.8
'@alicloud/openapi-util': 0.3.3
'@alicloud/tea-typescript': 1.8.0
'@alicloud/tea-util': 1.4.11
transitivePeerDependencies:
- supports-color
'@alicloud/gateway-spi@0.0.8':
dependencies:
'@alicloud/credentials': 2.4.5
'@alicloud/tea-typescript': 1.8.0
transitivePeerDependencies:
- supports-color
'@alicloud/openapi-client@0.4.15':
dependencies:
'@alicloud/credentials': 2.4.5
'@alicloud/gateway-spi': 0.0.8
'@alicloud/openapi-util': 0.3.3
'@alicloud/tea-typescript': 1.8.0
'@alicloud/tea-util': 1.4.9
'@alicloud/tea-xml': 0.0.3
transitivePeerDependencies:
- supports-color
'@alicloud/openapi-core@1.0.7':
dependencies:
'@alicloud/credentials': 2.4.5
'@alicloud/gateway-pop': 0.0.6
'@alicloud/gateway-spi': 0.0.8
'@darabonba/typescript': 1.0.4
transitivePeerDependencies:
- supports-color
'@alicloud/openapi-util@0.3.3':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@alicloud/tea-util': 1.4.9
kitx: 2.2.0
sm3: 1.0.3
transitivePeerDependencies:
- supports-color
'@alicloud/tea-typescript@1.8.0':
dependencies:
'@types/node': 12.20.55
httpx: 2.3.3
transitivePeerDependencies:
- supports-color
'@alicloud/tea-util@1.4.11':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@darabonba/typescript': 1.0.4
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/tea-util@1.4.9':
dependencies:
'@alicloud/tea-typescript': 1.8.0
kitx: 2.2.0
transitivePeerDependencies:
- supports-color
'@alicloud/tea-xml@0.0.3':
dependencies:
'@alicloud/tea-typescript': 1.8.0
'@types/xml2js': 0.4.14
xml2js: 0.6.2
transitivePeerDependencies:
- supports-color
'@angular-devkit/core@17.3.11(chokidar@3.6.0)':
dependencies:
ajv: 8.12.0
@@ -3794,6 +4031,17 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@darabonba/typescript@1.0.4':
dependencies:
'@alicloud/tea-typescript': 1.8.0
httpx: 2.3.3
lodash: 4.18.1
moment: 2.30.1
moment-timezone: 0.5.48
xml2js: 0.6.2
transitivePeerDependencies:
- supports-color
'@emotion/hash@0.8.0': {}
'@emotion/unitless@0.7.5': {}
@@ -4400,10 +4648,16 @@ snapshots:
dependencies:
'@types/express': 4.17.25
'@types/node@12.20.55': {}
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
'@types/node@22.20.0':
dependencies:
undici-types: 6.21.0
'@types/prop-types@15.7.15': {}
'@types/qs@6.15.1': {}
@@ -4436,6 +4690,10 @@ snapshots:
'@types/validator@13.15.10': {}
'@types/xml2js@0.4.14':
dependencies:
'@types/node': 20.19.43
'@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -4527,7 +4785,7 @@ snapshots:
'@typescript-eslint/types': 8.62.1
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -4535,7 +4793,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
vite: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
@@ -4756,7 +5014,7 @@ snapshots:
ansi-styles@6.2.3: {}
antd@5.29.3(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
antd@5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@ant-design/colors': 7.2.1
'@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4788,7 +5046,7 @@ snapshots:
rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-picker: 4.11.3(dayjs@1.11.21)(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-picker: 4.11.3(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -5149,6 +5407,8 @@ snapshots:
dotenv@16.4.5: {}
dotenv@17.4.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -5559,6 +5819,13 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
httpx@2.3.3:
dependencies:
'@types/node': 20.19.43
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@5.0.0: {}
humanize-ms@1.2.1:
@@ -5588,6 +5855,8 @@ snapshots:
inherits@2.0.4: {}
ini@1.3.8: {}
inquirer@8.2.6:
dependencies:
ansi-escapes: 4.3.2
@@ -5776,6 +6045,10 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kitx@2.2.0:
dependencies:
'@types/node': 22.20.0
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -5906,6 +6179,12 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
moment-timezone@0.5.48:
dependencies:
moment: 2.30.1
moment@2.30.1: {}
ms@2.0.0: {}
ms@2.1.3: {}
@@ -6300,7 +6579,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
rc-picker@4.11.3(dayjs@1.11.21)(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
rc-picker@4.11.3(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.29.7
'@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -6313,6 +6592,7 @@ snapshots:
optionalDependencies:
dayjs: 1.11.21
luxon: 3.7.2
moment: 2.30.1
rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@@ -6697,6 +6977,8 @@ snapshots:
signal-exit@4.1.0: {}
sm3@1.0.3: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -6978,13 +7260,13 @@ snapshots:
vary@1.1.2: {}
vite-node@1.6.1(@types/node@20.19.43)(terser@5.48.0):
vite-node@1.6.1(@types/node@22.20.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
vite: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -6996,17 +7278,17 @@ snapshots:
- supports-color
- terser
vite@5.4.21(@types/node@20.19.43)(terser@5.48.0):
vite@5.4.21(@types/node@22.20.0)(terser@5.48.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.62.2
optionalDependencies:
'@types/node': 20.19.43
'@types/node': 22.20.0
fsevents: 2.3.3
terser: 5.48.0
vitest@1.6.1(@types/node@20.19.43)(terser@5.48.0):
vitest@1.6.1(@types/node@22.20.0)(terser@5.48.0):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@@ -7025,11 +7307,11 @@ snapshots:
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.21(@types/node@20.19.43)(terser@5.48.0)
vite-node: 1.6.1(@types/node@20.19.43)(terser@5.48.0)
vite: 5.4.21(@types/node@22.20.0)(terser@5.48.0)
vite-node: 1.6.1(@types/node@22.20.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.43
'@types/node': 22.20.0
transitivePeerDependencies:
- less
- lightningcss
+1
View File
@@ -3,6 +3,7 @@ packages:
- 'packages/*'
- 'server/*'
allowBuilds:
'@alicloud/openapi-core': set this to true or false
'@nestjs/core': true
'@prisma/client': true
'@prisma/engines': true
+40 -27
View File
@@ -1,4 +1,5 @@
const API = 'http://localhost:3000/api/v1';
const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
async function req(clientApp, path, options = {}) {
const headers = {
@@ -12,15 +13,27 @@ async function req(clientApp, path, options = {}) {
return json.data;
}
async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
await req(clientApp, sendPath, {
method: 'POST',
body: JSON.stringify({ phone, scene }),
});
}
async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') {
await sendSms(clientApp, phone, scene, sendPath);
return req(clientApp, loginPath, {
method: 'POST',
body: JSON.stringify({ phone, code: MOCK_SMS_CODE }),
});
}
async function main() {
console.log('1. Health');
await req('USER_H5', '/health');
console.log('2. User login');
const userLogin = await req('USER_H5', '/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
});
const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN');
const userToken = userLogin.accessToken;
console.log('3. Catalog');
@@ -48,40 +61,40 @@ async function main() {
});
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
console.log('5. Benefit granted');
console.log('5. Shop login + redeem preview');
const shopLogin = await loginSms(
'SHOP_H5',
'13900000001',
'STORE_LOGIN',
'/shop/auth/login/sms',
'/shop/auth/sms/send',
);
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
if (!coupons.length) throw new Error('No coupon after pay');
console.log('6. Redeem token');
const tokenData = await req('USER_H5', '/redeem/tokens', {
if (!coupons.length) throw new Error('No coupons');
const tokenRes = await req('USER_H5', '/redeem/tokens', {
method: 'POST',
token: userToken,
body: JSON.stringify({ couponId: coupons[0].id, amount: 50 }),
body: JSON.stringify({ amount: 50 }),
});
console.log('7. Shop redeem');
const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13900000001', code: '123456' }),
});
await req('SHOP_H5', '/shop/redeem/confirm', {
await req('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
token: shopLogin.accessToken,
body: JSON.stringify({ token: tokenData.token }),
body: JSON.stringify({ token: tokenRes.token }),
});
console.log('8. Partner orders');
const partnerLogin = await req('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13700000001', code: '123456' }),
});
const orders = await req('PARTNER_H5', '/partner/orders', { token: partnerLogin.accessToken });
if (!orders.list.length) throw new Error('Partner should see orders');
console.log('6. Partner login');
await loginSms(
'PARTNER_H5',
'13700000001',
'PARTNER_LOGIN',
'/partner/auth/login/sms',
'/partner/auth/sms/send',
);
console.log('\n✅ preV1 smoke passed');
console.log('preV1 smoke OK');
}
main().catch((e) => {
console.error('❌ Smoke failed:', e.message);
console.error(e);
process.exit(1);
});
+31 -11
View File
@@ -24,22 +24,39 @@ async function expectFail(clientApp, path, options = {}) {
return json.message;
}
async function adminLogin() {
return req('HQ_WEB', '/admin/auth/login/sms', {
const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
await req(clientApp, sendPath, {
method: 'POST',
body: JSON.stringify({ phone: '13600000001', code: '123456' }),
body: JSON.stringify({ phone, scene }),
});
}
async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') {
await sendSms(clientApp, phone, scene, sendPath);
return req(clientApp, loginPath, {
method: 'POST',
body: JSON.stringify({ phone, code: MOCK_SMS_CODE }),
});
}
async function adminLogin() {
return loginSms(
'HQ_WEB',
'13600000001',
'HQ_LOGIN',
'/admin/auth/login/sms',
'/admin/auth/sms/send',
);
}
async function main() {
console.log('1. Health');
await req('USER_H5', '/health');
console.log('2. User login');
const userLogin = await req('USER_H5', '/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
});
const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN');
const userToken = userLogin.accessToken;
console.log('3. Cities + products');
@@ -105,10 +122,13 @@ async function main() {
});
console.log('8. Shop confirm redeem');
const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13900000001', code: '123456' }),
});
const shopLogin = await loginSms(
'SHOP_H5',
'13900000001',
'STORE_LOGIN',
'/shop/auth/login/sms',
'/shop/auth/sms/send',
);
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
token: shopLogin.accessToken,
+10 -3
View File
@@ -1,7 +1,9 @@
# 杜康 API 环境变量模板
# 开发:cp .env.development.example .env.development → 本地再 cp 为 .env(改 PORT/DATABASE_URL
# 生产:cp .env.production.example .env.production
# 服务器同步:bash deploy/sync-api-env.sh development|production
# 本地推荐分层:
# .env.development — 集成开关与密钥(MOCK_SMS、阿里云、微信)
# .env — 仅本机项(PORT、DATABASE_URL、REDIS_URL
# .env.local — 可选本机覆盖(gitignore)
# 生产/预发:bash deploy/sync-api-env.sh development|production
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
@@ -10,6 +12,11 @@ JWT_EXPIRES_IN="7d"
PORT=3000
MOCK_SMS=true
MOCK_SMS_CODE=123456
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
@@ -12,6 +12,10 @@ PORT=8090
MOCK_SMS=false
MOCK_SMS_CODE=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=false
MOCK_DELIVERY_AUTO=false
AUTO_APPROVE_STORE=false
+3
View File
@@ -15,6 +15,8 @@
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
"@alicloud/openapi-client": "^0.4.15",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@nestjs/bullmq": "^10.2.0",
@@ -28,6 +30,7 @@
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
+1 -1
View File
@@ -20,7 +20,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { loadAppConfig } from '@dukang/shared-types';
import { SmsCodeStore } from './sms/sms-code.store';
import { SmsMockProvider } from './sms/sms.mock.provider';
import { SmsAliyunProvider } from './sms/sms.aliyun.provider';
import { PayMockProvider } from './pay/pay.mock.provider';
import { PayWechatProvider } from './pay/pay.wechat.provider';
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
@@ -21,11 +23,26 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
import type { IWechatProvider } from './wechat/wechat.interface';
import type { IPayProvider } from './pay/pay.interface';
import type { IOssProvider } from './oss/oss.interface';
import type { ISmsProvider } from './sms/sms.interface';
@Module({
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
providers: [
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
SmsCodeStore,
SmsMockProvider,
SmsAliyunProvider,
{
provide: SMS_PROVIDER,
useFactory: (mock: SmsMockProvider, aliyun: SmsAliyunProvider): ISmsProvider => {
const cfg = loadAppConfig();
if (cfg.mockSms) return mock;
if (!aliyun.isEnabled()) {
throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing');
}
return aliyun;
},
inject: [SmsMockProvider, SmsAliyunProvider],
},
WechatApiProvider,
WechatDisabledProvider,
{
@@ -60,9 +77,8 @@ import type { IOssProvider } from './oss/oss.interface';
},
inject: [OssMockProvider, OssAliyunProvider],
},
SmsMockProvider,
DeliveryMockProvider,
],
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
})
export class IntegrationsModule {}
@@ -0,0 +1,51 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { RedisService } from '../../common/redis/redis.service';
const CODE_TTL_SECONDS = 300;
const RATE_TTL_SECONDS = 60;
function codeKey(phone: string, scene: string) {
return `dukang:sms:${scene}:${phone}`;
}
function rateKey(phone: string) {
return `dukang:sms:rate:${phone}`;
}
function randomSixDigitCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}
@Injectable()
export class SmsCodeStore {
private readonly config = loadAppConfig();
constructor(private readonly redis: RedisService) {}
async assertSendCooldown(phone: string) {
const ttl = await this.redis.ttl(rateKey(phone));
if (ttl > 0) {
throw new BadRequestException('发送过于频繁,请稍后再试');
}
}
async setSendCooldown(phone: string) {
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
}
async generateAndStore(phone: string, scene: string): Promise<string> {
const code = this.config.mockSms ? this.config.mockSmsCode : randomSixDigitCode();
await this.redis.client.set(codeKey(phone, scene), code, 'EX', CODE_TTL_SECONDS);
return code;
}
async verifyAndConsume(phone: string, scene: string, code: string) {
const key = codeKey(phone, scene);
const stored = await this.redis.client.get(key);
if (!stored || stored !== code.trim()) {
throw new BadRequestException('验证码错误或已过期');
}
await this.redis.del(key);
}
}
@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import Dysmsapi20170525, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
import * as OpenApi from '@alicloud/openapi-client';
import { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { ISmsProvider } from './sms.interface';
import { SmsCodeStore } from './sms-code.store';
function serializeSmsResponseBody(body: unknown): Record<string, string> | undefined {
if (!body || typeof body !== 'object') return undefined;
const src = body as Record<string, unknown>;
const out: Record<string, string> = {};
for (const key of ['code', 'message', 'requestId', 'bizId'] as const) {
const value = src[key];
if (value != null && typeof value !== 'function') {
out[key] = String(value);
}
}
return Object.keys(out).length ? out : undefined;
}
@Injectable()
export class SmsAliyunProvider implements ISmsProvider {
private readonly logger = new Logger(SmsAliyunProvider.name);
private readonly config = loadAppConfig();
private client: Dysmsapi20170525 | null = null;
constructor(
private readonly smsCodeStore: SmsCodeStore,
private readonly prisma: PrismaService,
) {}
isEnabled() {
return !!(
this.config.aliyunSmsAccessKeyId &&
this.config.aliyunSmsAccessKeySecret &&
this.config.aliyunSmsSignName &&
this.config.aliyunSmsTemplateCode
);
}
private getClient() {
if (!this.isEnabled()) {
throw new Error('Aliyun SMS is not configured');
}
if (!this.client) {
const openApiConfig = new OpenApi.Config({
accessKeyId: this.config.aliyunSmsAccessKeyId,
accessKeySecret: this.config.aliyunSmsAccessKeySecret,
endpoint: 'dysmsapi.aliyuncs.com',
});
this.client = new Dysmsapi20170525(openApiConfig);
}
return this.client;
}
async send(phone: string, scene: string): Promise<void> {
const code = await this.smsCodeStore.generateAndStore(phone, scene);
const request = new SendSmsRequest({
phoneNumbers: phone,
signName: this.config.aliyunSmsSignName,
templateCode: this.config.aliyunSmsTemplateCode,
templateParam: JSON.stringify({ code }),
});
let logged = false;
try {
const response = await this.getClient().sendSms(request);
const bizId = response.body?.bizId ?? undefined;
const ok = response.body?.code === 'OK';
const responseBody = serializeSmsResponseBody(response.body);
await this.prisma.logThirdParty.create({
data: {
provider: 'SMS',
scene,
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
responseBody,
externalNo: bizId,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed',
},
});
logged = true;
if (!ok) {
throw new Error(response.body?.message ?? '短信发送失败');
}
this.logger.log(`Aliyun SMS sent to ${phone.slice(0, 3)}****${phone.slice(-4)} scene=${scene} bizId=${bizId ?? '-'}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Aliyun SMS send failed: ${message}`);
if (!logged) {
await this.prisma.logThirdParty.create({
data: {
provider: 'SMS',
scene,
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
status: 'FAILED',
errorMessage: message.slice(0, 512),
},
});
}
throw err;
}
}
async verify(phone: string, code: string, scene: string): Promise<void> {
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
}
}
@@ -1,21 +1,34 @@
import { Injectable } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { ISmsProvider } from './sms.interface';
import { SmsCodeStore } from './sms-code.store';
@Injectable()
export class SmsMockProvider implements ISmsProvider {
private readonly config = loadAppConfig();
private readonly logger = new Logger(SmsMockProvider.name);
async send(_phone: string, _scene: string): Promise<void> {
if (!this.config.mockSms) {
throw new Error('Real SMS not implemented in preV1');
}
constructor(
private readonly smsCodeStore: SmsCodeStore,
private readonly prisma: PrismaService,
) {}
async send(phone: string, scene: string): Promise<void> {
const code = await this.smsCodeStore.generateAndStore(phone, scene);
const masked = `${phone.slice(0, 3)}****${phone.slice(-4)}`;
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
await this.prisma.logThirdParty.create({
data: {
provider: 'SMS',
scene,
requestBody: { phone: masked, mode: 'MOCK', scene },
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
status: 'SUCCESS',
},
});
}
async verify(phone: string, code: string, _scene: string): Promise<void> {
if (this.config.mockSms && code === this.config.mockSmsCode) {
return;
}
throw new Error(`Invalid verification code for ${phone}`);
async verify(phone: string, code: string, scene: string): Promise<void> {
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { config } from 'dotenv';
import { existsSync } from 'fs';
import { resolve } from 'path';
/**
* 分层加载环境变量(后加载的文件覆盖先前的同名键):
* 1. .env.{NODE_ENV} — 集成配置(MOCK_SMS、微信、阿里云等)
* 2. .env.{NODE_ENV}.local / .env.local — 本机覆盖
* 3. .env — 本机基础(PORT、DATABASE_URL);应只放机器相关项,勿重复 MOCK_SMS
*/
const apiRoot = resolve(__dirname, '..');
const nodeEnv = process.env.NODE_ENV ?? 'development';
const layers = [
resolve(apiRoot, `.env.${nodeEnv}`),
resolve(apiRoot, `.env.${nodeEnv}.local`),
resolve(apiRoot, '.env.local'),
resolve(apiRoot, '.env'),
];
for (const file of layers) {
if (existsSync(file)) {
config({ path: file, override: true });
}
}
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = nodeEnv;
}
+5
View File
@@ -1,4 +1,6 @@
import './load-env';
import { NestFactory } from '@nestjs/core';
import { loadAppConfig } from '@dukang/shared-types';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { json } from 'express';
@@ -24,6 +26,9 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseInterceptor());
const port = process.env.PORT || 3000;
const cfg = loadAppConfig();
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
await app.listen(port);
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [IamModule],
imports: [forwardRef(() => IamModule)],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -2,6 +2,14 @@ import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
export type TrackEventInput = {
eventName: string;
pagePath?: string;
refType?: string;
refId?: bigint;
extraJson?: Record<string, unknown>;
};
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
@@ -13,13 +21,36 @@ export class AnalyticsService {
) {
if (!events?.length) return { count: 0 };
await this.prisma.logUserAnalytics.createMany({
data: events.map((e) => ({
userId,
data: events.map((e) => this.toRow(userId, clientApp, {
eventName: e.eventName,
extraJson: e.params as never,
clientApp: clientApp as ClientApp,
extraJson: e.params,
refType: typeof e.params?.refType === 'string' ? e.params.refType : undefined,
refId: e.params?.refId != null ? BigInt(String(e.params.refId)) : undefined,
pagePath: typeof e.params?.pagePath === 'string' ? e.params.pagePath : undefined,
})),
});
return { count: events.length };
}
async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
await this.prisma.logUserAnalytics.create({
data: this.toRow(userId, clientApp, event),
});
}
trackOneSafe(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
void this.trackOne(userId, clientApp, event).catch(() => {});
}
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
return {
userId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
pagePath: event.pagePath,
refType: event.refType,
refId: event.refId,
extraJson: event.extraJson as never,
};
}
}
@@ -9,6 +9,7 @@ export class ClientConfigController {
return {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
mockSms: cfg.mockSms,
};
}
}
@@ -15,8 +15,11 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import { ISmsProvider } from '../../integrations/sms/sms.interface';
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import type { User } from '@prisma/client';
@@ -53,10 +56,33 @@ export class AuthService {
private readonly redis: RedisService,
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
private readonly analyticsService: AnalyticsService,
private readonly smsCodeStore: SmsCodeStore,
private readonly userAddressService: UserAddressService,
) {}
private assertMobilePhone(phone: string) {
const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
throw new BadRequestException('请输入正确的手机号码');
}
return trimmed;
}
async sendSms(phone: string, scene: string) {
await this.smsProvider.send(phone, scene);
const normalizedPhone = this.assertMobilePhone(phone);
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
throw new BadRequestException('无效的验证码场景');
}
await this.smsCodeStore.assertSendCooldown(normalizedPhone);
try {
await this.smsProvider.send(normalizedPhone, scene);
await this.smsCodeStore.setSendCooldown(normalizedPhone);
} catch (err) {
if (err instanceof BadRequestException) throw err;
const message = err instanceof Error ? err.message : '短信发送失败';
throw new BadRequestException(message);
}
return { sent: true };
}
@@ -111,9 +137,10 @@ export class AuthService {
}
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.USER_LOGIN);
let user: UserRow | null = await this.prisma.user.findUnique({
where: { phone },
where: { phone: normalizedPhone },
include: { avatar: true },
});
@@ -125,9 +152,9 @@ export class AuthService {
user = await this.prisma.user.update({
where: { id: guestId },
data: {
phone,
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
@@ -139,10 +166,10 @@ export class AuthService {
if (!user) {
user = await this.prisma.user.create({
data: {
phone,
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
nickname: `用户${normalizedPhone.slice(-4)}`,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -170,30 +197,40 @@ export class AuthService {
if (!user) throw new BadRequestException('登录失败');
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'sms_login',
extraJson: { method: 'sms' },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'sms' },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.BIND_PHONE);
const guest = await this.assertActiveUser(actorId);
if (guest.phone && guest.phoneVerifiedAt) {
if (guest.phone === phone) {
if (guest.phone === normalizedPhone) {
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
}
throw new BadRequestException('当前账号已绑定其他手机号');
}
const existing = await this.prisma.user.findUnique({ where: { phone } });
const existing = await this.prisma.user.findUnique({ where: { phone: normalizedPhone } });
let targetUser: UserRow;
if (!existing) {
targetUser = await this.prisma.user.update({
where: { id: guest.id },
data: {
phone,
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
@@ -210,9 +247,10 @@ export class AuthService {
}
async loginStore(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.STORE_LOGIN);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
const account = await this.prisma.storeAccount.findUnique({
where: { phone },
where: { phone: normalizedPhone },
include: { store: true },
});
if (!account) throw new BadRequestException('门店账号不存在');
@@ -230,9 +268,10 @@ export class AuthService {
}
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.PARTNER_LOGIN);
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
where: { phone: normalizedPhone },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
@@ -251,8 +290,9 @@ export class AuthService {
}
async loginHq(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.HQ_LOGIN);
const account = await this.prisma.hqAccount.findUnique({ where: { phone } });
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.HQ_LOGIN);
const account = await this.prisma.hqAccount.findUnique({ where: { phone: normalizedPhone } });
if (!account) throw new BadRequestException('HQ账号不存在');
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
await this.prisma.hqAccount.update({
@@ -331,6 +371,14 @@ export class AuthService {
},
include: { avatar: true },
});
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'wechat_login',
extraJson: { platform },
});
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
}
@@ -460,6 +508,14 @@ export class AuthService {
if (!targetUserId) throw new BadRequestException('绑定失败');
const user = await this.assertActiveUser(targetUserId);
await this.redis.del(`wx:session:${wxSessionKey}`);
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'wechat_phone',
extraJson: { method: 'bind_phone' },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat_bind' },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
@@ -646,6 +702,7 @@ export class AuthService {
});
});
await this.userAddressService.normalizeDefaultAddress(primaryId);
return this.assertActiveUser(primaryId);
}
@@ -1,4 +1,5 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { SmsScene } from '@dukang/shared-types';
export class SendSmsDto {
@IsString()
@@ -7,6 +8,7 @@ export class SendSmsDto {
@IsString()
@IsNotEmpty()
@IsIn(Object.values(SmsScene))
scene: string;
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { AuthService } from './auth.service';
import {
PartnerAuthController,
@@ -19,6 +20,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
@Module({
imports: [
IntegrationsModule,
forwardRef(() => AnalyticsModule),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
@@ -33,6 +35,6 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
AdminAuthController,
],
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
exports: [AuthService, UserAddressService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
})
export class IamModule {}
@@ -14,22 +14,37 @@ export class UserAddressService {
return serializeBigInt(list);
}
async normalizeDefaultAddress(userId: bigint) {
const defaults = await this.prisma.userAddress.findMany({
where: { userId, isDefault: 1 },
orderBy: { updatedAt: 'desc' },
});
if (defaults.length <= 1) return;
const keep = defaults[0];
await this.prisma.$transaction(async (tx) => {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
await tx.userAddress.update({ where: { id: keep.id }, data: { isDefault: 1 } });
});
}
async create(userId: bigint, body: Record<string, unknown>) {
const isDefault = body.isDefault ? 1 : 0;
if (isDefault) {
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
const address = await this.prisma.userAddress.create({
data: {
userId,
receiverName: String(body.receiverName),
phone: String(body.phone),
province: String(body.province),
city: String(body.city),
district: String(body.district),
detail: String(body.detail),
isDefault,
},
const address = await this.prisma.$transaction(async (tx) => {
if (isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
return tx.userAddress.create({
data: {
userId,
receiverName: String(body.receiverName),
phone: String(body.phone),
province: String(body.province),
city: String(body.city),
district: String(body.district),
detail: String(body.detail),
isDefault,
},
});
});
return serializeBigInt(address);
}
@@ -37,20 +52,22 @@ export class UserAddressService {
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
if (!existing) throw new NotFoundException('地址不存在');
if (body.isDefault) {
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
const address = await this.prisma.userAddress.update({
where: { id },
data: {
receiverName: body.receiverName ? String(body.receiverName) : undefined,
phone: body.phone ? String(body.phone) : undefined,
province: body.province ? String(body.province) : undefined,
city: body.city ? String(body.city) : undefined,
district: body.district ? String(body.district) : undefined,
detail: body.detail ? String(body.detail) : undefined,
isDefault: body.isDefault ? 1 : undefined,
},
const address = await this.prisma.$transaction(async (tx) => {
if (body.isDefault) {
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
}
return tx.userAddress.update({
where: { id },
data: {
receiverName: body.receiverName ? String(body.receiverName) : undefined,
phone: body.phone ? String(body.phone) : undefined,
province: body.province ? String(body.province) : undefined,
city: body.city ? String(body.city) : undefined,
district: body.district ? String(body.district) : undefined,
detail: body.detail ? String(body.detail) : undefined,
isDefault: body.isDefault ? 1 : undefined,
},
});
});
return serializeBigInt(address);
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
@@ -27,11 +28,23 @@ export class AdminProductsService {
}),
this.prisma.commonProductItem.count({ where }),
]);
const productIds = items.map((p) => p.id);
const resources = productIds.length
? await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: { in: productIds },
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
})
: [];
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt({
items: items.map((p) => ({
...p,
mainImageUrl: p.coverResource?.url ?? null,
})),
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
total,
page,
pageSize,
@@ -44,7 +57,18 @@ export class AdminProductsService {
include: { coverResource: true },
});
if (!product) throw new NotFoundException('商品不存在');
return serializeBigInt({ ...product, mainImageUrl: product.coverResource?.url ?? null });
const resources = await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: id,
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
});
return serializeBigInt(this.formatProduct(product, resources));
}
async create(dto: CreateProductDto) {
@@ -65,26 +89,19 @@ export class AdminProductsService {
benefitAmount: dto.benefitAmount ?? dto.price,
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
},
});
if (dto.coverUrl) {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: product.id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
await this.syncCover(product.id, dto.coverUrl);
}
await this.syncProductMedia(product.id, {
carouselUrls: dto.carouselUrls,
detailImageUrls: dto.detailImageUrls,
});
return this.detail(product.id);
}
@@ -101,35 +118,96 @@ export class AdminProductsService {
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
},
});
if (dto.coverUrl) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } });
if (product.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: product.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id },
data: { coverResourceId: cover.id },
});
}
await this.syncCover(id, dto.coverUrl);
}
await this.syncProductMedia(id, {
carouselUrls: dto.carouselUrls,
detailImageUrls: dto.detailImageUrls,
});
return this.detail(id);
}
private formatProduct(
product: Prisma.CommonProductItemGetPayload<{ include: { coverResource: true } }>,
extraResources: Prisma.CommonResourceGetPayload<object>[],
) {
const media = mapProductMedia(product, extraResources);
return {
...product,
price: Number(product.price),
benefitAmount: Number(product.benefitAmount ?? product.price),
...media,
};
}
private async syncCover(productId: bigint, coverUrl: string) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } });
if (product.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: product.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'PRODUCT',
ownerId: productId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.commonProductItem.update({
where: { id: productId },
data: { coverResourceId: cover.id },
});
}
}
private async syncProductMedia(
productId: bigint,
dto: { carouselUrls?: string[]; detailImageUrls?: string[] },
) {
if (dto.carouselUrls !== undefined) {
await this.replaceProductResources(productId, 'CAROUSEL', dto.carouselUrls);
}
if (dto.detailImageUrls !== undefined) {
await this.replaceProductResources(productId, 'DETAIL', dto.detailImageUrls);
}
}
private async replaceProductResources(
productId: bigint,
bizType: 'CAROUSEL' | 'DETAIL',
urls: string[],
) {
const cleaned = urls.map((u) => u?.trim()).filter(Boolean);
await this.prisma.commonResource.deleteMany({
where: { ownerType: 'PRODUCT', ownerId: productId, bizType },
});
if (cleaned.length === 0) return;
await this.prisma.commonResource.createMany({
data: cleaned.map((url, sortOrder) => ({
ownerType: 'PRODUCT' as const,
ownerId: productId,
bizType,
mediaType: 'IMAGE' as const,
ossBucket: 'legacy',
ossKey: url,
url,
sortOrder,
status: 'ACTIVE' as const,
})),
});
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminUserLogsService } from './admin-user-logs.service';
import { AdminUserLogsQueryDto } from './dto/admin-query.dto';
@Controller('admin/logs/users')
@UseGuards(HqAuthGuard)
export class AdminUserLogsController {
constructor(private readonly service: AdminUserLogsService) {}
@Get()
list(@Query() query: AdminUserLogsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,112 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { eventNamesForUserLogCategory, resolveUserLogCategory } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminUserLogsQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminUserLogsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminUserLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.LogUserAnalyticsWhereInput = {};
if (query.userId) {
where.userId = BigInt(query.userId);
} else if (query.phone || query.userNo) {
const userWhere: Prisma.UserWhereInput = {};
if (query.phone) userWhere.phone = { contains: query.phone };
if (query.userNo) userWhere.userNo = { contains: query.userNo };
const users = await this.prisma.user.findMany({
where: userWhere,
select: { id: true },
take: 100,
});
if (users.length === 0) {
return { items: [], total: 0, page, pageSize };
}
where.userId = { in: users.map((u) => u.id) };
}
if (query.eventName) {
where.eventName = query.eventName;
} else if (query.category) {
const names = eventNamesForUserLogCategory(query.category);
if (names?.length) {
where.eventName = { in: names };
}
}
if (query.from || query.to) {
where.createdAt = {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
};
}
const [rows, total] = await Promise.all([
this.prisma.logUserAnalytics.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logUserAnalytics.count({ where }),
]);
const userIds = [...new Set(rows.map((r) => r.userId).filter((id): id is bigint => id != null))];
const users = userIds.length
? await this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, userNo: true, phone: true, nickname: true },
})
: [];
const userMap = new Map(users.map((u) => [u.id.toString(), u]));
return serializeBigInt({
items: rows.map((row) => {
const user = row.userId ? userMap.get(row.userId.toString()) : undefined;
return {
id: row.id,
userId: row.userId,
userNo: user?.userNo ?? null,
phone: user?.phone ?? null,
nickname: user?.nickname ?? null,
category: resolveUserLogCategory(row.eventName),
eventName: row.eventName,
clientApp: row.clientApp,
refType: row.refType,
refId: row.refId,
extraJson: row.extraJson,
createdAt: row.createdAt,
};
}),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.logUserAnalytics.findUnique({ where: { id } });
if (!row) throw new NotFoundException('日志不存在');
const user = row.userId
? await this.prisma.user.findUnique({
where: { id: row.userId },
select: { id: true, userNo: true, phone: true, nickname: true },
})
: null;
return serializeBigInt({
...row,
userNo: user?.userNo ?? null,
phone: user?.phone ?? null,
nickname: user?.nickname ?? null,
category: resolveUserLogCategory(row.eventName),
});
}
}
@@ -1,4 +1,4 @@
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -391,6 +391,20 @@ export class CreateProductDto {
@IsOptional()
@IsString()
coverUrl?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
carouselUrls?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
detailImageUrls?: string[];
@IsOptional()
@IsObject()
detailContent?: Record<string, unknown>;
}
export class UpdateProductDto {
@@ -425,4 +439,18 @@ export class UpdateProductDto {
@IsOptional()
@IsString()
coverUrl?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
carouselUrls?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
detailImageUrls?: string[];
@IsOptional()
@IsObject()
detailContent?: Record<string, unknown>;
}
@@ -227,6 +227,36 @@ export class AdminProductsQueryDto extends PaginationQueryDto {
aromaType?: string;
}
export class AdminUserLogsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
userNo?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
eventName?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@@ -21,6 +21,8 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminProductsController } from './admin-products.controller';
import { AdminProductsService } from './admin-products.service';
import { AdminUserLogsController } from './admin-user-logs.controller';
import { AdminUserLogsService } from './admin-user-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@@ -45,6 +47,7 @@ import { CommonModule } from '../common/common.module';
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
AdminUserLogsController,
AdminTicketsController,
],
providers: [
@@ -59,6 +62,7 @@ import { CommonModule } from '../common/common.module';
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
AdminUserLogsService,
AdminTicketsService,
SuperAdminGuard,
],
@@ -1,4 +1,5 @@
import { Module, forwardRef } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module';
@@ -6,7 +7,7 @@ import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)],
controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService],
exports: [RedeemService],
@@ -14,6 +14,7 @@ import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service';
@@ -32,6 +33,7 @@ export class RedeemService {
private readonly redis: RedisService,
private readonly settlementService: SettlementService,
private readonly benefitService: BenefitService,
private readonly analyticsService: AnalyticsService,
) {}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
@@ -197,6 +199,17 @@ export class RedeemService {
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
await this.redis.del(`redeem:token:${body.token}`);
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
eventName: 'benefit_redeem_success',
refType: 'STORE',
refId: account.storeId,
extraJson: {
redeemRecordId: record.id.toString(),
storeId: account.storeId.toString(),
amount,
},
});
return serializeBigInt(record);
}
@@ -1,4 +1,5 @@
import { Module, forwardRef } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
@@ -8,7 +9,7 @@ import { TradeController, PartnerOrderController, PartnerReshipmentController }
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService],
exports: [TradeService],
@@ -14,6 +14,7 @@ import {
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { TicketService } from '../common/ticket.service';
@@ -37,6 +38,7 @@ export class TradeService {
private readonly ipGeoService: IpGeoService,
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -158,6 +160,17 @@ export class TradeService {
include: { product: true, imageResource: true },
});
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'order_submit',
refType: 'ORDER',
refId: order.id,
extraJson: {
orderId: order.id.toString(),
productId: body.productId,
quantity: body.quantity,
},
});
return serializeBigInt(mapOrderCompat(order));
}
@@ -226,6 +239,13 @@ export class TradeService {
await this.benefitService.grantOnOrderPaid(order.id);
await this.deliveryProvider.scheduleAutoAdvance(order.id);
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
refId: order.id,
extraJson: { orderId: order.id.toString(), mode: 'mock' },
});
return this.getOrder(userId, orderId);
}
@@ -305,6 +325,12 @@ export class TradeService {
if (refreshed?.payStatus === 'PAID') {
await this.benefitService.grantOnOrderPaid(order.id);
await this.deliveryProvider.scheduleAutoAdvance(order.id);
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
eventName: 'pay_success',
refType: 'ORDER',
refId: order.id,
extraJson: { orderId: order.id.toString(), mode: 'wechat_callback' },
});
}
return { orderId: order.id.toString(), alreadyPaid: false };