Merge #6 into dev from dev_jacy
小飞侠、短信验证还有位置服务等 * dev_jacy: (4 commits) 短信验证调试成功 短信验证绑定日志内容 定位获取城市功能,需要微信地图的key? 小飞侠接口 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/6
This commit is contained in:
@@ -13,6 +13,7 @@ import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
|||||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||||
import DeliveriesPage from './pages/DeliveriesPage';
|
import DeliveriesPage from './pages/DeliveriesPage';
|
||||||
|
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||||
import HqAccountsPage from './pages/HqAccountsPage';
|
import HqAccountsPage from './pages/HqAccountsPage';
|
||||||
import CitiesPage from './pages/CitiesPage';
|
import CitiesPage from './pages/CitiesPage';
|
||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
import StoreMediaPage from './pages/StoreMediaPage';
|
||||||
@@ -21,6 +22,7 @@ import ResourcesPage from './pages/ResourcesPage';
|
|||||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
import StorePayoutsPage from './pages/StorePayoutsPage';
|
||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
@@ -56,8 +58,10 @@ export default function App() {
|
|||||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<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="/deliveries" element={<DeliveriesPage />} />
|
||||||
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
|
|
||||||
@@ -57,8 +58,24 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
},
|
},
|
||||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
||||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||||
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
|
{
|
||||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
key: 'logs-group',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
label: '日志',
|
||||||
|
children: [
|
||||||
|
{ key: '/logs/users', label: '用户日志' },
|
||||||
|
{ key: '/logs/third-party', label: '第三方日志' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deliveries-group',
|
||||||
|
icon: <CarOutlined />,
|
||||||
|
label: '配送单',
|
||||||
|
children: [
|
||||||
|
{ key: '/deliveries', label: '配送单列表' },
|
||||||
|
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -86,7 +103,7 @@ export default function AdminLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group']}
|
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
||||||
items={MENU_ITEMS}
|
items={MENU_ITEMS}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
if (key.startsWith('/')) navigate(key);
|
if (key.startsWith('/')) navigate(key);
|
||||||
|
|||||||
@@ -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', 'sms_send', 'sms_verify_fail'],
|
||||||
|
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||||
|
order: ['order_confirm_view', 'order_submit'],
|
||||||
|
pay: ['pay_success', 'pay_fail'],
|
||||||
|
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||||
|
browse_store: ['store_list_view', 'store_detail_view'],
|
||||||
|
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||||
|
profile: ['profile_update', 'bind_phone'],
|
||||||
|
};
|
||||||
|
|
||||||
|
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>;
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
|
||||||
|
type ProductDetailContentDto = {
|
||||||
|
storyTitle?: string;
|
||||||
|
storyText?: string;
|
||||||
|
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
skuCode: string;
|
skuCode: string;
|
||||||
@@ -21,9 +29,196 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
|
carouselUrls?: string[];
|
||||||
|
detailImageUrls?: string[];
|
||||||
|
detailContent?: ProductDetailContentDto | null;
|
||||||
createdAt: string;
|
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() {
|
export default function ProductsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
@@ -60,10 +255,7 @@ export default function ProductsPage() {
|
|||||||
<Button type="link" size="small" onClick={async () => {
|
<Button type="link" size="small" onClick={async () => {
|
||||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||||
setDetail(d);
|
setDetail(d);
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue(mapDetailToForm(d));
|
||||||
...d,
|
|
||||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
|
||||||
});
|
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
),
|
),
|
||||||
@@ -88,11 +280,12 @@ export default function ProductsPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
<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); } }} />
|
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 && (
|
extra={detail && (
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" onClick={async () => {
|
||||||
const v = await editForm.validateFields();
|
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('已保存');
|
message.success('已保存');
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
void reload();
|
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.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Tabs items={[
|
||||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
|
||||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input /></Form.Item>
|
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
||||||
<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>
|
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
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('已创建');
|
message.success('已创建');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
void reload();
|
void reload();
|
||||||
}} width={520}>
|
}} width={720}>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}><Input /></Form.Item>
|
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}><Input /></Form.Item>
|
carouselUrls: [''], detailImageUrls: [''],
|
||||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
}}>
|
||||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
<Tabs items={[
|
||||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
||||||
</Form.Item>
|
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
||||||
<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>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -24,6 +25,7 @@ type UserDetail = AdminUserRow & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -99,11 +101,16 @@ export default function UsersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
<Space size="small">
|
||||||
详情
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
</Button>
|
详情
|
||||||
|
</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>
|
</Drawer>
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
||||||
|
Tabs, Tag, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type XfxConfig = {
|
||||||
|
provider: string;
|
||||||
|
activeProvider: string;
|
||||||
|
apiUrl: string;
|
||||||
|
appId: string | null;
|
||||||
|
mchId: string | null;
|
||||||
|
mchIdMasked: string | null;
|
||||||
|
hasApiKey: boolean;
|
||||||
|
signType: string;
|
||||||
|
ready: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiResult = {
|
||||||
|
ok: boolean;
|
||||||
|
elapsedMs: number;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
provider?: string;
|
||||||
|
raw?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_DEFAULTS = {
|
||||||
|
outNumber: `TEST-${Date.now()}`,
|
||||||
|
fromName: '杜康仓库',
|
||||||
|
fromMobile: '13800000000',
|
||||||
|
fromAddress: '河南省郑州市金水区',
|
||||||
|
fromAddressDetail: '杜康酒业仓',
|
||||||
|
fromLng: 113.665,
|
||||||
|
fromLat: 34.757,
|
||||||
|
toName: '测试收货人',
|
||||||
|
toMobile: '13800000001',
|
||||||
|
toAddress: '河南省郑州市二七区',
|
||||||
|
toAddressDetail: '测试路 1 号',
|
||||||
|
toLng: 113.640,
|
||||||
|
toLat: 34.720,
|
||||||
|
goodsName: '杜康酒',
|
||||||
|
goodsNum: 2,
|
||||||
|
weight: 2,
|
||||||
|
payMode: '1',
|
||||||
|
remark: 'Admin 联调测试',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResultPanel({ result }: { result: ApiResult | null }) {
|
||||||
|
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Tag color={result.ok ? 'green' : 'red'}>{result.ok ? '成功' : '失败'}</Tag>
|
||||||
|
<span>{result.elapsedMs} ms</span>
|
||||||
|
{result.code && <span>code: {result.code}</span>}
|
||||||
|
</Space>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
|
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XiaofeixiaTestPage() {
|
||||||
|
const [config, setConfig] = useState<XfxConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<ApiResult | null>(null);
|
||||||
|
|
||||||
|
const [freightForm] = Form.useForm();
|
||||||
|
const [coverageForm] = Form.useForm();
|
||||||
|
const [createForm] = Form.useForm();
|
||||||
|
const [queryForm] = Form.useForm();
|
||||||
|
const [batchForm] = Form.useForm();
|
||||||
|
const [trackForm] = Form.useForm();
|
||||||
|
const [cancelForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
||||||
|
createForm.setFieldsValue(CREATE_DEFAULTS);
|
||||||
|
freightForm.setFieldsValue({ weight: 2 });
|
||||||
|
coverageForm.setFieldsValue({ toAddress: '河南省郑州市二七区测试路1号' });
|
||||||
|
}, [createForm, freightForm, coverageForm]);
|
||||||
|
|
||||||
|
async function invoke(path: string, body: unknown) {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await request<ApiResult>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setResult(res);
|
||||||
|
if (res.ok) message.success('调用成功');
|
||||||
|
else message.warning(res.error || '调用失败');
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
|
message.error(err);
|
||||||
|
setResult({ ok: false, elapsedMs: 0, error: err });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(text?: string) {
|
||||||
|
return (text ?? '').split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置
|
||||||
|
{' '}<code>XIAOFEIXIA_MCH_ID</code>、<code>XIAOFEIXIA_API_KEY</code>。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
{config && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions column={3} size="small">
|
||||||
|
<Descriptions.Item label="Provider">{config.activeProvider}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="API">{config.apiUrl}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商户号">{config.mchIdMasked || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="签名">{config.signType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="密钥">
|
||||||
|
{config.hasApiKey ? <Tag color="green">已配置</Tag> : <Tag color="red">未配置</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{config.ready ? <Tag color="green">可调用</Tag> : <Tag color="orange">配置不完整</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{!config.ready && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'freight',
|
||||||
|
label: '运费预估 (100105)',
|
||||||
|
children: (
|
||||||
|
<Form form={freightForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/estimate-freight', v)}>
|
||||||
|
<Form.Item name="weight" label="重量 (kg)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coverage',
|
||||||
|
label: '配送范围 (100301)',
|
||||||
|
children: (
|
||||||
|
<Form form={coverageForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/check-coverage', v)}>
|
||||||
|
<Form.Item name="toAddress" label="收件地址" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={2} placeholder="完整收件地址" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
label: '创建订单 (100101)',
|
||||||
|
children: (
|
||||||
|
<Form form={createForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/create-shipment', v)}>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>寄件人</Typography.Text>
|
||||||
|
<Form.Item name="fromName" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromMobile" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromAddress" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromAddressDetail" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="fromLng" label="经度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="fromLat" label="纬度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>收件人</Typography.Text>
|
||||||
|
<Form.Item name="toName" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toMobile" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toAddress" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toAddressDetail" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="toLng" label="经度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="toLat" label="纬度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={8}><Form.Item name="goodsName" label="货品"><Input /></Form.Item></Col>
|
||||||
|
<Col span={8}><Form.Item name="goodsNum" label="件数"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
|
||||||
|
<Col span={8}><Form.Item name="weight" label="重量 kg"><InputNumber min={0.01} style={{ width: '100%' }} /></Form.Item></Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||||
|
<Select options={[
|
||||||
|
{ value: '1', label: '寄付' },
|
||||||
|
{ value: '2', label: '到付' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注"><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
<Button onClick={() => createForm.setFieldsValue({ ...CREATE_DEFAULTS, outNumber: `TEST-${Date.now()}` })}>
|
||||||
|
重置示例
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'query',
|
||||||
|
label: '查询订单 (100104)',
|
||||||
|
children: (
|
||||||
|
<Form form={queryForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-shipment', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号 number"><Input placeholder="小飞侠运单号" /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'batch',
|
||||||
|
label: '批量查询 (100106)',
|
||||||
|
children: (
|
||||||
|
<Form form={batchForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/batch-get-shipments', {
|
||||||
|
trackingNumbers: parseList(v.trackingNumbersText),
|
||||||
|
outNumbers: parseList(v.outNumbersText),
|
||||||
|
})}>
|
||||||
|
<Form.Item name="trackingNumbersText" label="运单号(逗号分隔)">
|
||||||
|
<Input.TextArea rows={2} placeholder="NO001,NO002" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outNumbersText" label="商家单号(逗号分隔)">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'track',
|
||||||
|
label: '路由查询 (100102)',
|
||||||
|
children: (
|
||||||
|
<Form form={trackForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-track', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cancel',
|
||||||
|
label: '取消订单 (100103)',
|
||||||
|
children: (
|
||||||
|
<Form form={cancelForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/cancel-shipment', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号"><Input /></Form.Item>
|
||||||
|
<Button type="primary" danger htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title="响应结果" size="small">
|
||||||
|
<ResultPanel result={result} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import RedeemPage from './pages/RedeemPage';
|
|||||||
import RedeemCodePage from './pages/RedeemCodePage';
|
import RedeemCodePage from './pages/RedeemCodePage';
|
||||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
import PayPage from './pages/PayPage';
|
import PayPage from './pages/PayPage';
|
||||||
|
import CustomerServicePage from './pages/CustomerServicePage';
|
||||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||||
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||||
<Route path="/pay" element={<PayPage />} />
|
<Route path="/pay" element={<PayPage />} />
|
||||||
|
<Route path="/customer-service" element={<CustomerServicePage />} />
|
||||||
<Route path="/addresses" element={<AddressListPage />} />
|
<Route path="/addresses" element={<AddressListPage />} />
|
||||||
<Route path="/addresses/new" element={<AddressEditPage />} />
|
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||||
<Route path="/addresses/:id/edit" 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 { 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 { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
import { useSmsCode } from '../lib/use-sms-code';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
|
||||||
type PhoneVerifySheetProps = {
|
type PhoneVerifySheetProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -16,18 +19,18 @@ export default function PhoneVerifySheet({
|
|||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: PhoneVerifySheetProps) {
|
}: PhoneVerifySheetProps) {
|
||||||
|
const { applySession } = useUserSession();
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setPhone('');
|
setPhone('');
|
||||||
setCode('');
|
setCode('');
|
||||||
setMsg('');
|
setError('');
|
||||||
setCodeCooldown(0);
|
clearMessages();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (defaultPhone) {
|
if (defaultPhone) {
|
||||||
@@ -36,51 +39,32 @@ export default function PhoneVerifySheet({
|
|||||||
setPhone(normalized);
|
setPhone(normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, defaultPhone]);
|
}, [open, defaultPhone, clearMessages, setError]);
|
||||||
|
|
||||||
async function sendCode() {
|
async function onSendCode() {
|
||||||
const phoneCheck = validateMobilePhone(phone);
|
clearMessages();
|
||||||
if (!phoneCheck.ok) {
|
await sendCode(phone, SmsScene.BIND_PHONE);
|
||||||
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 submit() {
|
async function submit() {
|
||||||
const phoneCheck = validateMobilePhone(phone);
|
const phoneCheck = validateMobilePhone(phone);
|
||||||
if (!phoneCheck.ok) {
|
if (!phoneCheck.ok) {
|
||||||
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!code.trim()) {
|
if (!code.trim()) {
|
||||||
setMsg('请输入验证码');
|
setError('请输入验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const session = await bindPhone(phone, code);
|
const session = await bindPhone(phone, code);
|
||||||
saveSession(session);
|
applySession(session as SessionPayload);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '验证失败');
|
setError(e instanceof Error ? e.message : '验证失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -105,7 +89,8 @@ export default function PhoneVerifySheet({
|
|||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPhone(normalizePhoneInput(e.target.value));
|
setPhone(normalizePhoneInput(e.target.value));
|
||||||
setMsg('');
|
setError('');
|
||||||
|
clearMessages();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,19 +100,26 @@ export default function PhoneVerifySheet({
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="login-field-input"
|
className="login-field-input"
|
||||||
placeholder="请输入验证码"
|
placeholder="请输入验证码"
|
||||||
|
maxLength={6}
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
|
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||||
disabled={codeCooldown > 0}
|
disabled={codeCooldown > 0 || sending}
|
||||||
onClick={sendCode}
|
onClick={onSendCode}
|
||||||
>
|
>
|
||||||
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
|
{sending
|
||||||
|
? '发送中...'
|
||||||
|
: codeCooldown > 0
|
||||||
|
? `${codeCooldown}s 后重新获取`
|
||||||
|
: '获取验证码'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}>
|
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
|
||||||
{loading ? '验证中...' : '确认验证'}
|
{loading ? '验证中...' : '确认验证'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ensureSession,
|
ensureSession,
|
||||||
getDeviceKey,
|
getDeviceKey,
|
||||||
request,
|
request,
|
||||||
|
saveSession,
|
||||||
type SessionPayload,
|
type SessionPayload,
|
||||||
type UserProfile,
|
type UserProfile,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
@@ -21,6 +22,7 @@ type UserSessionContextValue = {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
profile: UserProfile | null;
|
profile: UserProfile | null;
|
||||||
phoneVerified: boolean;
|
phoneVerified: boolean;
|
||||||
|
applySession: (session: SessionPayload) => void;
|
||||||
refreshProfile: () => Promise<void>;
|
refreshProfile: () => Promise<void>;
|
||||||
resetSession: () => Promise<void>;
|
resetSession: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -33,6 +35,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
const [phoneVerified, setPhoneVerified] = useState(false);
|
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||||
|
|
||||||
const applySession = useCallback((session: SessionPayload) => {
|
const applySession = useCallback((session: SessionPayload) => {
|
||||||
|
saveSession(session);
|
||||||
if (session.user) setProfile(session.user);
|
if (session.user) setProfile(session.user);
|
||||||
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
|
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -83,10 +86,11 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
ready,
|
ready,
|
||||||
profile,
|
profile,
|
||||||
phoneVerified,
|
phoneVerified,
|
||||||
|
applySession,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
resetSession,
|
resetSession,
|
||||||
}),
|
}),
|
||||||
[ready, profile, phoneVerified, refreshProfile, resetSession],
|
[ready, profile, phoneVerified, applySession, refreshProfile, resetSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -70,6 +70,31 @@ export const DEFAULT_REGION: RegionSelection = {
|
|||||||
district: '金水区',
|
district: '金水区',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FALLBACK_CITY_REGION: RegionSelection = {
|
||||||
|
province: '河南省',
|
||||||
|
city: '郑州市',
|
||||||
|
district: REGION_ALL,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||||
|
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||||
|
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||||
|
const cities = getCities(provinceInTree);
|
||||||
|
const matchedCity = cities.includes(cityName)
|
||||||
|
? cityName
|
||||||
|
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
|
||||||
|
const districts = getDistricts(provinceInTree, matchedCity);
|
||||||
|
const districtName =
|
||||||
|
district && districts.includes(district)
|
||||||
|
? district
|
||||||
|
: REGION_ALL;
|
||||||
|
return normalizeRegionSelection({
|
||||||
|
province: provinceInTree,
|
||||||
|
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
|
||||||
|
district: districtName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验已选地区是否仍存在于数据源中 */
|
/** 校验已选地区是否仍存在于数据源中 */
|
||||||
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||||
if (selection.province === REGION_ALL) {
|
if (selection.province === REGION_ALL) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||||
|
import { apiBase } from './api';
|
||||||
|
import { weixinSdk } from './weixin';
|
||||||
|
import { regionFromGeo, type RegionSelection } from './region-data';
|
||||||
|
|
||||||
|
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||||
|
export const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||||
|
export const FALLBACK_CITY_CODE = '410100';
|
||||||
|
|
||||||
|
export type ResolvedUserCity = {
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
cityCode?: string;
|
||||||
|
cityName?: string;
|
||||||
|
openCity: boolean;
|
||||||
|
region: RegionSelection;
|
||||||
|
displayCity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||||
|
|
||||||
|
function readCache(): GpsCityCache | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as GpsCityCache;
|
||||||
|
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(data: ResolvedUserCity) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
GPS_CITY_STORAGE_KEY,
|
||||||
|
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportLocationToServer(payload: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
sdk: 'jssdk' | 'geolocation';
|
||||||
|
status: 'success' | 'fail';
|
||||||
|
errMsg?: string;
|
||||||
|
}) {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': 'USER_H5',
|
||||||
|
};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/common/wechat/location`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) {
|
||||||
|
throw new Error(json.message || '定位上报失败');
|
||||||
|
}
|
||||||
|
return json.data as {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
cityName?: string;
|
||||||
|
openCity?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResolved(data: {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
cityName?: string;
|
||||||
|
openCity?: boolean;
|
||||||
|
}): ResolvedUserCity | null {
|
||||||
|
if (!data.province || !data.city) return null;
|
||||||
|
const region = regionFromGeo(data.province, data.city, data.district);
|
||||||
|
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`);
|
||||||
|
return {
|
||||||
|
province: data.province,
|
||||||
|
city: data.city,
|
||||||
|
district: data.district ?? '',
|
||||||
|
cityCode: data.cityCode,
|
||||||
|
cityName: data.cityName,
|
||||||
|
openCity: !!data.openCity,
|
||||||
|
region,
|
||||||
|
displayCity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */
|
||||||
|
export async function resolveUserCity(force = false): Promise<ResolvedUserCity | null> {
|
||||||
|
if (!force) {
|
||||||
|
const cached = readCache();
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await getWechatLocationDetailed({
|
||||||
|
apiBase,
|
||||||
|
clientApp: 'USER_H5',
|
||||||
|
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outcome.location) {
|
||||||
|
await reportLocationToServer({
|
||||||
|
sdk: outcome.sdk,
|
||||||
|
status: 'fail',
|
||||||
|
errMsg: outcome.errMsg,
|
||||||
|
}).catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await reportLocationToServer({
|
||||||
|
latitude: outcome.location.latitude,
|
||||||
|
longitude: outcome.location.longitude,
|
||||||
|
sdk: outcome.sdk,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
const resolved = toResolved(data);
|
||||||
|
if (resolved) {
|
||||||
|
writeCache(resolved);
|
||||||
|
if (resolved.openCity && resolved.cityCode) {
|
||||||
|
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCityCodeFromGps(resolved: ResolvedUserCity) {
|
||||||
|
if (resolved.openCity && resolved.cityCode) {
|
||||||
|
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import SubPageHeader from '../components/SubPageHeader';
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
|
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
|
||||||
|
|
||||||
type Address = {
|
type Address = {
|
||||||
@@ -24,6 +25,7 @@ function formatAddress(a: Address) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddressListPage() {
|
export default function AddressListPage() {
|
||||||
|
const { profile } = useUserSession();
|
||||||
const [list, setList] = useState<Address[]>([]);
|
const [list, setList] = useState<Address[]>([]);
|
||||||
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
||||||
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
||||||
@@ -42,7 +44,7 @@ export default function AddressListPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadList();
|
loadList();
|
||||||
}, [loadList]);
|
}, [loadList, profile?.id]);
|
||||||
|
|
||||||
function selectAddress(addr: Address) {
|
function selectAddress(addr: Address) {
|
||||||
if (!selectMode) return;
|
if (!selectMode) return;
|
||||||
@@ -137,7 +139,7 @@ export default function AddressListPage() {
|
|||||||
|
|
||||||
<div className="address-list-cards">
|
<div className="address-list-cards">
|
||||||
{list.map((a) => {
|
{list.map((a) => {
|
||||||
const isDefault = a.isDefault === 1;
|
const isDefault = Number(a.isDefault) === 1;
|
||||||
const isSelected = selectMode && currentAddressId === String(a.id);
|
const isSelected = selectMode && currentAddressId === String(a.id);
|
||||||
return (
|
return (
|
||||||
<article
|
<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,6 +5,12 @@ import ProductCarousel from '../components/ProductCarousel';
|
|||||||
import TabMainHeader from '../components/TabMainHeader';
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
import AppToast from '../components/AppToast';
|
import AppToast from '../components/AppToast';
|
||||||
import { getProductImages } from '../lib/product-images';
|
import { getProductImages } from '../lib/product-images';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
|
import {
|
||||||
|
CITY_STORAGE_KEY,
|
||||||
|
FALLBACK_CITY_CODE,
|
||||||
|
resolveUserCity,
|
||||||
|
} from '../lib/wechat-location';
|
||||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -33,15 +39,19 @@ const AROMA_TABS = [
|
|||||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CITY_STORAGE_KEY = 'dukang_selected_city';
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [tab, setTab] = useState('QINGXIANG');
|
const [tab, setTab] = useState('QINGXIANG');
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [cities, setCities] = useState<City[]>([]);
|
const [cities, setCities] = useState<City[]>([]);
|
||||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
|
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE);
|
||||||
|
const [locatedCityLabel, setLocatedCityLabel] = useState('');
|
||||||
|
const [citySource, setCitySource] = useState<'auto' | 'manual'>('auto');
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track('home_view', { pagePath: '/', cityCode });
|
||||||
|
}, [cityCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||||
setCities(list);
|
setCities(list);
|
||||||
@@ -51,6 +61,21 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (citySource !== 'auto') return;
|
||||||
|
resolveUserCity().then((resolved) => {
|
||||||
|
if (!resolved) return;
|
||||||
|
setLocatedCityLabel(resolved.displayCity);
|
||||||
|
if (resolved.openCity && resolved.cityCode) {
|
||||||
|
setCityCode(resolved.cityCode);
|
||||||
|
} else {
|
||||||
|
setCityCode(FALLBACK_CITY_CODE);
|
||||||
|
setToast('当前城市暂未开城,已展示郑州商品');
|
||||||
|
window.setTimeout(() => setToast(''), 2200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [citySource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cityCode) return;
|
if (!cityCode) return;
|
||||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||||
@@ -71,6 +96,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||||
|
const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市';
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
const onSale = tab === 'QINGXIANG';
|
const onSale = tab === 'QINGXIANG';
|
||||||
|
|
||||||
@@ -81,10 +107,15 @@ export default function HomePage() {
|
|||||||
extra={(
|
extra={(
|
||||||
<div className="tab-main-city">
|
<div className="tab-main-city">
|
||||||
<span className="material-symbols-outlined">location_on</span>
|
<span className="material-symbols-outlined">location_on</span>
|
||||||
|
<span className="tab-main-city-label">{headerCityLabel}</span>
|
||||||
<select
|
<select
|
||||||
value={cityCode}
|
value={cityCode}
|
||||||
onChange={(e) => setCityCode(e.target.value)}
|
onChange={(e) => {
|
||||||
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
|
setCitySource('manual');
|
||||||
|
setCityCode(e.target.value);
|
||||||
|
}}
|
||||||
|
className="tab-main-city-select"
|
||||||
|
aria-label="选择开城城市"
|
||||||
>
|
>
|
||||||
{cities.map((c) => (
|
{cities.map((c) => (
|
||||||
<option key={c.code} value={c.code}>{c.name}</option>
|
<option key={c.code} value={c.code}>{c.name}</option>
|
||||||
|
|||||||
@@ -2,22 +2,27 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
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 { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
import { useSmsCode } from '../lib/use-sms-code';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const returnTo = searchParams.get('return') || '/';
|
const returnTo = searchParams.get('return') || '/';
|
||||||
const [phone, setPhone] = useState('13800000001');
|
const { applySession } = useUserSession();
|
||||||
const [code, setCode] = useState('123456');
|
const [phone, setPhone] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [agreed, setAgreed] = useState(true);
|
const [agreed, setAgreed] = useState(true);
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||||
const [bindMode, setBindMode] = useState(false);
|
const [bindMode, setBindMode] = useState(false);
|
||||||
|
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||||
|
useSmsCode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWechatEnv()) return;
|
if (!isWechatEnv()) return;
|
||||||
@@ -38,12 +43,12 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.accessToken) {
|
if (result.accessToken) {
|
||||||
saveSession({
|
applySession({
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
refreshToken: result.refreshToken ?? '',
|
refreshToken: result.refreshToken ?? '',
|
||||||
deviceKey: result.deviceKey,
|
deviceKey: result.deviceKey,
|
||||||
phoneVerified: !!result.phoneVerified,
|
phoneVerified: !!result.phoneVerified,
|
||||||
user: result.user as never,
|
user: result.user as SessionPayload['user'],
|
||||||
});
|
});
|
||||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||||
}
|
}
|
||||||
@@ -57,29 +62,11 @@ export default function LoginPage() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCode() {
|
async function onSendCode() {
|
||||||
if (!ensureAgreed()) return;
|
if (!ensureAgreed()) return;
|
||||||
const phoneCheck = validateMobilePhone(phone);
|
clearMessages();
|
||||||
if (!phoneCheck.ok) {
|
|
||||||
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMsg('');
|
setMsg('');
|
||||||
await request('USER_H5', '/auth/sms/send', {
|
await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
@@ -95,6 +82,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
setSmsError('');
|
||||||
try {
|
try {
|
||||||
if (bindMode && wxSessionKey) {
|
if (bindMode && wxSessionKey) {
|
||||||
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||||
@@ -104,15 +92,11 @@ export default function LoginPage() {
|
|||||||
handleWechatLoginResult(data);
|
handleWechatLoginResult(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await request<{
|
const data = await request<SessionPayload>('USER_H5', '/auth/login/sms', {
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
deviceKey?: string;
|
|
||||||
}>('USER_H5', '/auth/login/sms', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveSession(data);
|
applySession(data);
|
||||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
@@ -136,6 +120,8 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayMsg = msg || smsError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<header className="login-header">
|
<header className="login-header">
|
||||||
@@ -164,6 +150,7 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPhone(normalizePhoneInput(e.target.value));
|
setPhone(normalizePhoneInput(e.target.value));
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
clearMessages();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,19 +160,28 @@ export default function LoginPage() {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="login-field-input"
|
className="login-field-input"
|
||||||
placeholder="请输入验证码"
|
placeholder="请输入验证码"
|
||||||
|
maxLength={6}
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
|
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||||
disabled={codeCooldown > 0}
|
disabled={codeCooldown > 0 || sending}
|
||||||
onClick={sendCode}
|
onClick={onSendCode}
|
||||||
>
|
>
|
||||||
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
|
{sending
|
||||||
|
? '发送中...'
|
||||||
|
: codeCooldown > 0
|
||||||
|
? `${codeCooldown}s 后重新获取`
|
||||||
|
: '获取验证码'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{msg && <p className="login-msg">{msg}</p>}
|
{(displayMsg || sentHint) && (
|
||||||
|
<p className={`login-msg${sentHint && !displayMsg ? ' login-msg--hint' : ''}`}>
|
||||||
|
{displayMsg || sentHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="login-sms-btn"
|
className="login-sms-btn"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import TabMainHeader from '../components/TabMainHeader';
|
|||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { useUserSession } from '../contexts/UserSessionContext';
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||||
|
|
||||||
const DEFAULT_AVATAR =
|
const DEFAULT_AVATAR =
|
||||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
'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 [benefitBalance, setBenefitBalance] = useState(0);
|
||||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
|
const [showCs, setShowCs] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -67,7 +69,7 @@ export default function MinePage() {
|
|||||||
|
|
||||||
function handleService(item: (typeof SERVICES)[number]) {
|
function handleService(item: (typeof SERVICES)[number]) {
|
||||||
if ('to' in item && item.to) return;
|
if ('to' in item && item.to) return;
|
||||||
if (item.action === 'cs') showToast('preV1:在线客服即将开放');
|
if (item.action === 'cs') setShowCs(true);
|
||||||
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
|
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +210,8 @@ export default function MinePage() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{toast && <div className="mine-toast">{toast}</div>}
|
{toast && <div className="mine-toast">{toast}</div>}
|
||||||
|
|
||||||
|
{showCs && <ContactCustomerSheet onClose={() => setShowCs(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { buildProductDetailUrl } from '../lib/navigation';
|
import { buildProductDetailUrl } from '../lib/navigation';
|
||||||
import { getProductMainImage } from '../lib/product-images';
|
import { getProductMainImage } from '../lib/product-images';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||||
import { useUserSession } from '../contexts/UserSessionContext';
|
import { useUserSession } from '../contexts/UserSessionContext';
|
||||||
|
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||||
|
|
||||||
type Address = {
|
type Address = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -76,6 +78,12 @@ export default function OrderConfirmPage() {
|
|||||||
});
|
});
|
||||||
}, [params]);
|
}, [params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (productId) {
|
||||||
|
track('order_confirm_view', { refType: 'PRODUCT', refId: productId, productId, quantity });
|
||||||
|
}
|
||||||
|
}, [productId, quantity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId || !addressId) return;
|
if (!productId || !addressId) return;
|
||||||
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
|
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
|
||||||
@@ -116,7 +124,12 @@ export default function OrderConfirmPage() {
|
|||||||
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
||||||
|
|
||||||
async function doSubmit() {
|
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', {
|
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { buildOrderAddressSelectUrl } from '../lib/navigation';
|
import { buildOrderAddressSelectUrl } from '../lib/navigation';
|
||||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||||
|
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||||
|
|
||||||
type OrderItem = {
|
type OrderItem = {
|
||||||
productName: string;
|
productName: string;
|
||||||
@@ -145,7 +146,11 @@ export default function OrderDetailPage() {
|
|||||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||||
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
||||||
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
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 productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||||
|
|
||||||
@@ -402,6 +407,16 @@ export default function OrderDetailPage() {
|
|||||||
申请退款
|
申请退款
|
||||||
</button>
|
</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 && (
|
{canConfirmReceive && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -416,18 +431,11 @@ export default function OrderDetailPage() {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{showCs && (
|
{showCs && (
|
||||||
<div className="modal-overlay" onClick={() => setShowCs(false)}>
|
<ContactCustomerSheet
|
||||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
orderId={order.id}
|
||||||
<div className="modal-grabber" />
|
orderNo={order.orderNo}
|
||||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>联系客服</h3>
|
onClose={() => setShowCs(false)}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function OrderListPage() {
|
|||||||
const items = (o.items as Array<Record<string, unknown>>) || [];
|
const items = (o.items as Array<Record<string, unknown>>) || [];
|
||||||
const item = items[0];
|
const item = items[0];
|
||||||
const isReshipDemo = i === 0 && tab === 'all';
|
const isReshipDemo = i === 0 && tab === 'all';
|
||||||
|
const isPendingPay = String(o.status) === 'PENDING_PAY';
|
||||||
return (
|
return (
|
||||||
<div key={String(o.id)} className="card">
|
<div key={String(o.id)} className="card">
|
||||||
<div className="card-row" style={{ marginBottom: 8 }}>
|
<div className="card-row" style={{ marginBottom: 8 }}>
|
||||||
@@ -63,7 +64,16 @@ export default function OrderListPage() {
|
|||||||
<div className="amount-lg">¥{Number(o.payAmount)}</div>
|
<div className="amount-lg">¥{Number(o.payAmount)}</div>
|
||||||
</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
|
<Link
|
||||||
to={`/orders/${o.id}${isReshipDemo ? '?type=reship' : ''}`}
|
to={`/orders/${o.id}${isReshipDemo ? '?type=reship' : ''}`}
|
||||||
className="btn btn-outline btn-pill"
|
className="btn btn-outline btn-pill"
|
||||||
|
|||||||
@@ -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 { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import ProductCarousel from '../components/ProductCarousel';
|
import ProductCarousel from '../components/ProductCarousel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||||
import type { ProductImageSource } from '../lib/product-images';
|
import type { ProductImageSource } from '../lib/product-images';
|
||||||
|
|
||||||
@@ -11,13 +13,9 @@ type Product = ProductImageSource & {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
price: number;
|
price: number;
|
||||||
benefitAmount?: number;
|
benefitAmount?: number;
|
||||||
|
detailContent?: ProductDetailContentDto | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURES = [
|
|
||||||
{ icon: 'water_drop', title: '泉水酿造', desc: '甘冽清甜 灵动自然' },
|
|
||||||
{ icon: 'grain', title: '精选五谷', desc: '传统比例 匠心发酵' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,6 +26,12 @@ export default function ProductDetailPage() {
|
|||||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id });
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
setHeaderSolid(window.scrollY > 100);
|
setHeaderSolid(window.scrollY > 100);
|
||||||
@@ -41,6 +45,8 @@ export default function ProductDetailPage() {
|
|||||||
const benefit = Number(product.benefitAmount ?? product.price);
|
const benefit = Number(product.benefitAmount ?? product.price);
|
||||||
const carouselImages = getProductCarouselImages(product);
|
const carouselImages = getProductCarouselImages(product);
|
||||||
const detailImages = getProductDetailImages(product);
|
const detailImages = getProductDetailImages(product);
|
||||||
|
const detail = product.detailContent ?? {};
|
||||||
|
const features = detail.features ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="product-detail-page">
|
<div className="product-detail-page">
|
||||||
@@ -107,31 +113,31 @@ export default function ProductDetailPage() {
|
|||||||
<h3>商品详情</h3>
|
<h3>商品详情</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailImages[0] && (
|
{detailImages.map((src, index) => (
|
||||||
<AppImage src={detailImages[0]} alt="" wrapperClassName="product-detail-banner" />
|
<AppImage key={`${src}-${index}`} src={src} alt="" wrapperClassName="product-detail-banner" />
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<div className="product-detail-copy">
|
{(detail.storyTitle || detail.storyText || features.length > 0) && (
|
||||||
<div className="product-detail-story">
|
<div className="product-detail-copy">
|
||||||
<h4>千年杜康 · 唯有此处</h4>
|
{(detail.storyTitle || detail.storyText) && (
|
||||||
<p>
|
<div className="product-detail-story">
|
||||||
选自白水杜康核心产区,取山泉之灵气,集五谷之精华。古法酿造工艺,历经九九八十一道工序,方得这一口醇厚绵甜。
|
{detail.storyTitle && <h4>{detail.storyTitle}</h4>}
|
||||||
</p>
|
{detail.storyText && <p>{detail.storyText}</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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{detailImages.length > 1 && (
|
{features.length > 0 && (
|
||||||
<AppImage src={detailImages[1]} alt="" wrapperClassName="product-detail-banner" />
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import ProductCarousel from '../components/ProductCarousel';
|
import ProductCarousel from '../components/ProductCarousel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
|
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
|
||||||
|
|
||||||
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
|
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
|
||||||
@@ -55,7 +56,10 @@ export default function StoreDetailPage() {
|
|||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
import TabMainHeader from '../components/TabMainHeader';
|
import TabMainHeader from '../components/TabMainHeader';
|
||||||
import RegionPicker from '../components/RegionPicker';
|
import RegionPicker from '../components/RegionPicker';
|
||||||
import {
|
import {
|
||||||
DEFAULT_REGION,
|
DEFAULT_REGION,
|
||||||
|
FALLBACK_CITY_REGION,
|
||||||
formatRegion,
|
formatRegion,
|
||||||
REGION_ALL,
|
REGION_ALL,
|
||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../lib/region-data';
|
} from '../lib/region-data';
|
||||||
|
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
|
||||||
|
|
||||||
type OpenCity = {
|
type OpenCity = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -61,23 +64,60 @@ export default function StoreListPage() {
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||||
|
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
|
||||||
|
const [usedFallback, setUsedFallback] = useState(false);
|
||||||
|
const [geoReady, setGeoReady] = useState(false);
|
||||||
|
|
||||||
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track('store_list_view', { pagePath: '/stores' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
|
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (filterMode !== 'auto' || geoReady) return;
|
||||||
|
resolveUserCity().then((resolved) => {
|
||||||
|
if (resolved) {
|
||||||
|
setRegion(resolved.region);
|
||||||
|
}
|
||||||
|
setGeoReady(true);
|
||||||
|
});
|
||||||
|
}, [filterMode, geoReady]);
|
||||||
|
|
||||||
|
const fetchStores = useCallback(
|
||||||
|
async (targetRegion: RegionSelection, allowFallback: boolean) => {
|
||||||
|
const code = resolveCityCode(targetRegion, cities);
|
||||||
|
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
|
||||||
|
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
allowFallback &&
|
||||||
|
filterMode === 'auto' &&
|
||||||
|
data.length === 0 &&
|
||||||
|
!usedFallback &&
|
||||||
|
code &&
|
||||||
|
code !== FALLBACK_CITY_CODE
|
||||||
|
) {
|
||||||
|
setUsedFallback(true);
|
||||||
|
setRegion(FALLBACK_CITY_REGION);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[cities, filterMode, usedFallback],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterMode === 'auto' && !geoReady) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
|
fetchStores(region, true)
|
||||||
request<StoreItem[]>('USER_H5', `/stores${qs}`)
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setStores(data);
|
if (!cancelled && data) setStores(data);
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setStores([]);
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -85,7 +125,7 @@ export default function StoreListPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [cityCode]);
|
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = stores;
|
let list = stores;
|
||||||
@@ -117,6 +157,10 @@ export default function StoreListPage() {
|
|||||||
}, [stores, categoryTab, keyword, region, cityCode]);
|
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||||
|
|
||||||
const regionLabel = formatRegion(region.province, region.city, region.district);
|
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||||
|
const emptyMessage =
|
||||||
|
filterMode === 'manual' && filtered.length === 0
|
||||||
|
? '未找到匹配门店'
|
||||||
|
: '暂无门店';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page store-page">
|
<div className="page store-page">
|
||||||
@@ -139,7 +183,10 @@ export default function StoreListPage() {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="搜索门店名称或地址"
|
placeholder="搜索门店名称或地址"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFilterMode('manual');
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +198,10 @@ export default function StoreListPage() {
|
|||||||
key={tab}
|
key={tab}
|
||||||
type="button"
|
type="button"
|
||||||
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
||||||
onClick={() => setCategoryTab(tab)}
|
onClick={() => {
|
||||||
|
setFilterMode('manual');
|
||||||
|
setCategoryTab(tab);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
@@ -210,7 +260,7 @@ export default function StoreListPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
<div className="store-empty">{emptyMessage}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
@@ -223,6 +273,7 @@ export default function StoreListPage() {
|
|||||||
value={region}
|
value={region}
|
||||||
onClose={() => setRegionPickerOpen(false)}
|
onClose={() => setRegionPickerOpen(false)}
|
||||||
onConfirm={(next) => {
|
onConfirm={(next) => {
|
||||||
|
setFilterMode('manual');
|
||||||
setRegion(next);
|
setRegion(next);
|
||||||
setRegionPickerOpen(false);
|
setRegionPickerOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -603,6 +603,24 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
max-width: 46vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-main-city-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-main-city-select {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
max-width: 72px;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-main-city .material-symbols-outlined {
|
.tab-main-city .material-symbols-outlined {
|
||||||
@@ -5541,3 +5559,254 @@
|
|||||||
line-height: 1.5;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||||
|
'@dukang/shared-types': path.resolve(__dirname, '../../packages/shared-types/src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -4,6 +4,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ export interface ProductDto {
|
|||||||
carouselUrls?: string[];
|
carouselUrls?: string[];
|
||||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||||
detailImageUrls?: string[];
|
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 {
|
export interface ProductListQuery {
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ export interface AppConfig {
|
|||||||
wxMchId: string;
|
wxMchId: string;
|
||||||
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
||||||
ossEnabled: boolean;
|
ossEnabled: boolean;
|
||||||
|
aliyunSmsSignName: string;
|
||||||
|
aliyunSmsTemplateCode: string;
|
||||||
|
aliyunSmsAccessKeyId: string;
|
||||||
|
aliyunSmsAccessKeySecret: string;
|
||||||
|
/** 腾讯位置服务 Key(逆地理编码) */
|
||||||
|
tencentLbsKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 总部客服电话(C 端联系客服) */
|
||||||
|
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
|
||||||
|
|
||||||
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
|
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
|
||||||
const e =
|
const e =
|
||||||
env ??
|
env ??
|
||||||
@@ -28,5 +37,10 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
wxAppId: e.WX_APP_ID ?? '',
|
wxAppId: e.WX_APP_ID ?? '',
|
||||||
wxMchId: e.WX_MCH_ID ?? '',
|
wxMchId: e.WX_MCH_ID ?? '',
|
||||||
ossEnabled: e.OSS_ENABLED === 'true',
|
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 ?? '',
|
||||||
|
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './redeem';
|
|||||||
export * from './settlement';
|
export * from './settlement';
|
||||||
export * from './ops';
|
export * from './ops';
|
||||||
export * from './ticket';
|
export * from './ticket';
|
||||||
|
export * from './user-log';
|
||||||
|
|||||||
@@ -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', 'sms_send', 'sms_verify_fail'],
|
||||||
|
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||||
|
order: ['order_confirm_view', 'order_submit'],
|
||||||
|
pay: ['pay_success', 'pay_fail'],
|
||||||
|
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||||
|
browse_store: ['store_list_view', 'store_detail_view'],
|
||||||
|
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||||
|
profile: ['profile_update', 'bind_phone'],
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
|||||||
export type ClientRuntimeConfig = {
|
export type ClientRuntimeConfig = {
|
||||||
mockPay: boolean;
|
mockPay: boolean;
|
||||||
wechatPayEnabled: boolean;
|
wechatPayEnabled: boolean;
|
||||||
|
mockSms: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface WechatLoginResult {
|
export interface WechatLoginResult {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
|||||||
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
|
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
|
||||||
export {
|
export {
|
||||||
getWechatLocation,
|
getWechatLocation,
|
||||||
|
getWechatLocationDetailed,
|
||||||
canUseWechatLocation,
|
canUseWechatLocation,
|
||||||
isWechatEnv,
|
isWechatEnv,
|
||||||
} from './location';
|
} from './location';
|
||||||
|
export type { WechatLocationOutcome } from './location';
|
||||||
export { scanQrCode } from './scan';
|
export { scanQrCode } from './scan';
|
||||||
export { invokeWechatPay } from './pay';
|
export { invokeWechatPay } from './pay';
|
||||||
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
|
export { chooseWechatImages, canUseWechatChooseImage } from './chooseImage';
|
||||||
@@ -24,7 +26,7 @@ export { DEFAULT_JS_API_LIST } from './types';
|
|||||||
|
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
import { initWechatJssdk } from './jssdk';
|
import { initWechatJssdk } from './jssdk';
|
||||||
import { getWechatLocation } from './location';
|
import { getWechatLocation, getWechatLocationDetailed } from './location';
|
||||||
import { scanQrCode } from './scan';
|
import { scanQrCode } from './scan';
|
||||||
import { invokeWechatPay } from './pay';
|
import { invokeWechatPay } from './pay';
|
||||||
import { chooseWechatImages } from './chooseImage';
|
import { chooseWechatImages } from './chooseImage';
|
||||||
@@ -45,6 +47,7 @@ export function createWeixinSdk(config: WeixinSdkConfig) {
|
|||||||
bindWechatPhone(config, payload),
|
bindWechatPhone(config, payload),
|
||||||
getPhoneNumber: () => getWechatPhoneNumber(config),
|
getPhoneNumber: () => getWechatPhoneNumber(config),
|
||||||
getLocation: () => getWechatLocation(config),
|
getLocation: () => getWechatLocation(config),
|
||||||
|
getLocationDetailed: () => getWechatLocationDetailed(config),
|
||||||
scanQrCode: () => scanQrCode(config),
|
scanQrCode: () => scanQrCode(config),
|
||||||
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
chooseImages: (options?: Parameters<typeof chooseWechatImages>[1]) =>
|
||||||
chooseWechatImages(config, options),
|
chooseWechatImages(config, options),
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import { getRuntimePlatform, isWechatBrowser } from './env';
|
|||||||
import { ensureJssdkReady } from './jssdk';
|
import { ensureJssdkReady } from './jssdk';
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
|
|
||||||
|
export type WechatLocationOutcome = {
|
||||||
|
sdk: 'jssdk' | 'geolocation';
|
||||||
|
location: WechatGpsLocation | null;
|
||||||
|
errMsg?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
|
||||||
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
|
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
|
||||||
|
const outcome = await getWechatLocationDetailed(config);
|
||||||
|
return outcome.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWechatLocationDetailed(
|
||||||
|
config?: WeixinSdkConfig,
|
||||||
|
): Promise<WechatLocationOutcome> {
|
||||||
const platform = getRuntimePlatform();
|
const platform = getRuntimePlatform();
|
||||||
|
|
||||||
if (platform === 'mini' && window.wx?.getLocation) {
|
if (platform === 'mini' && window.wx?.getLocation) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
window.wx!.getLocation!({
|
window.wx!.getLocation!({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
success: (res) => resolve(res),
|
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||||
fail: () => resolve(null),
|
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -25,29 +38,51 @@ export async function getWechatLocation(config?: WeixinSdkConfig): Promise<Wecha
|
|||||||
getAccessToken: config.getAccessToken,
|
getAccessToken: config.getAccessToken,
|
||||||
});
|
});
|
||||||
if (window.wx?.getLocation) {
|
if (window.wx?.getLocation) {
|
||||||
|
const jsApiOk = await new Promise<boolean>((resolve) => {
|
||||||
|
window.wx!.checkJsApi?.({
|
||||||
|
jsApiList: ['getLocation'],
|
||||||
|
success: (res) => resolve(!!res.checkResult?.getLocation),
|
||||||
|
fail: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!jsApiOk) {
|
||||||
|
return { sdk: 'jssdk', location: null, errMsg: 'getLocation 未授权或不可用' };
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
window.wx!.getLocation!({
|
window.wx!.getLocation!({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
success: (res) => resolve(res),
|
success: (res) => resolve({ sdk: 'jssdk', location: res }),
|
||||||
fail: () => resolve(null),
|
fail: (res) => resolve({ sdk: 'jssdk', location: null, errMsg: res.errMsg }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
return { sdk: 'jssdk', location: null, errMsg: '微信 JSSDK getLocation 不可用' };
|
||||||
/* fall through */
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { sdk: 'jssdk', location: null, errMsg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
|
return { sdk: 'geolocation', location: null, errMsg: '浏览器不支持定位' };
|
||||||
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) =>
|
(pos) =>
|
||||||
resolve({
|
resolve({
|
||||||
latitude: pos.coords.latitude,
|
sdk: 'geolocation',
|
||||||
longitude: pos.coords.longitude,
|
location: {
|
||||||
accuracy: pos.coords.accuracy,
|
latitude: pos.coords.latitude,
|
||||||
|
longitude: pos.coords.longitude,
|
||||||
|
accuracy: pos.coords.accuracy,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(err) =>
|
||||||
|
resolve({
|
||||||
|
sdk: 'geolocation',
|
||||||
|
location: null,
|
||||||
|
errMsg: err.message || '定位失败',
|
||||||
}),
|
}),
|
||||||
() => resolve(null),
|
|
||||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
|
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+305
-23
@@ -31,7 +31,7 @@ importers:
|
|||||||
version: link:../../packages/shared-types
|
version: link:../../packages/shared-types
|
||||||
antd:
|
antd:
|
||||||
specifier: ^5.22.0
|
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:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.21
|
version: 1.11.21
|
||||||
@@ -56,13 +56,13 @@ importers:
|
|||||||
version: 18.3.7(@types/react@18.3.31)
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.1
|
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:
|
typescript:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.0
|
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:
|
apps/h5-partner:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -96,13 +96,13 @@ importers:
|
|||||||
version: 18.3.7(@types/react@18.3.31)
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.1
|
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:
|
typescript:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.0
|
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:
|
apps/h5-shop:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -133,13 +133,13 @@ importers:
|
|||||||
version: 18.3.7(@types/react@18.3.31)
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.1
|
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:
|
typescript:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.0
|
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:
|
apps/h5-user:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -173,13 +173,13 @@ importers:
|
|||||||
version: 18.3.7(@types/react@18.3.31)
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.1
|
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:
|
typescript:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.0
|
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:
|
packages/domain:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -188,7 +188,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^1.6.0
|
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:
|
packages/shared-types:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -220,6 +220,12 @@ importers:
|
|||||||
|
|
||||||
server/dukang-api:
|
server/dukang-api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@alicloud/dysmsapi20170525':
|
||||||
|
specifier: ^4.6.0
|
||||||
|
version: 4.6.0
|
||||||
|
'@alicloud/openapi-client':
|
||||||
|
specifier: ^0.4.15
|
||||||
|
version: 0.4.15
|
||||||
'@dukang/domain':
|
'@dukang/domain':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/domain
|
version: link:../../packages/domain
|
||||||
@@ -259,6 +265,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.1
|
specifier: ^0.14.1
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.4.2
|
||||||
|
version: 17.4.2
|
||||||
express:
|
express:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.22.1
|
version: 4.22.1
|
||||||
@@ -305,6 +314,60 @@ importers:
|
|||||||
|
|
||||||
packages:
|
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':
|
'@angular-devkit/core@17.3.11':
|
||||||
resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==}
|
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'}
|
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==}
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@darabonba/typescript@1.0.4':
|
||||||
|
resolution: {integrity: sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==}
|
||||||
|
|
||||||
'@emotion/hash@0.8.0':
|
'@emotion/hash@0.8.0':
|
||||||
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
|
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
|
||||||
|
|
||||||
@@ -1115,9 +1181,15 @@ packages:
|
|||||||
'@types/multer@2.1.0':
|
'@types/multer@2.1.0':
|
||||||
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
|
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':
|
'@types/node@20.19.43':
|
||||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
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':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
@@ -1147,6 +1219,9 @@ packages:
|
|||||||
'@types/validator@13.15.10':
|
'@types/validator@13.15.10':
|
||||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.62.1':
|
||||||
resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==}
|
resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1733,6 +1808,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
|
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dotenv@17.4.2:
|
||||||
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2068,6 +2147,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
httpx@2.3.3:
|
||||||
|
resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==}
|
||||||
|
|
||||||
human-signals@5.0.0:
|
human-signals@5.0.0:
|
||||||
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
|
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
|
||||||
engines: {node: '>=16.17.0'}
|
engines: {node: '>=16.17.0'}
|
||||||
@@ -2105,6 +2187,9 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ini@1.3.8:
|
||||||
|
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||||
|
|
||||||
inquirer@8.2.6:
|
inquirer@8.2.6:
|
||||||
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
|
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -2261,6 +2346,9 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
kitx@2.2.0:
|
||||||
|
resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2423,6 +2511,12 @@ packages:
|
|||||||
mlly@1.8.2:
|
mlly@1.8.2:
|
||||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
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:
|
ms@2.0.0:
|
||||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||||
|
|
||||||
@@ -3087,6 +3181,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
sm3@1.0.3:
|
||||||
|
resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3590,6 +3687,146 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
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)':
|
'@angular-devkit/core@17.3.11(chokidar@3.6.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 8.12.0
|
ajv: 8.12.0
|
||||||
@@ -3794,6 +4031,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.9
|
'@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/hash@0.8.0': {}
|
||||||
|
|
||||||
'@emotion/unitless@0.7.5': {}
|
'@emotion/unitless@0.7.5': {}
|
||||||
@@ -4400,10 +4648,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/express': 4.17.25
|
'@types/express': 4.17.25
|
||||||
|
|
||||||
|
'@types/node@12.20.55': {}
|
||||||
|
|
||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@22.20.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
'@types/qs@6.15.1': {}
|
'@types/qs@6.15.1': {}
|
||||||
@@ -4436,6 +4690,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/validator@13.15.10': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -4527,7 +4785,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.62.1
|
'@typescript-eslint/types': 8.62.1
|
||||||
eslint-visitor-keys: 5.0.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:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@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
|
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.17.0
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -4756,7 +5014,7 @@ snapshots:
|
|||||||
|
|
||||||
ansi-styles@6.2.3: {}
|
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:
|
dependencies:
|
||||||
'@ant-design/colors': 7.2.1
|
'@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)
|
'@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-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-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-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-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-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)
|
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@16.4.5: {}
|
||||||
|
|
||||||
|
dotenv@17.4.2: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -5559,6 +5819,13 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
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: {}
|
human-signals@5.0.0: {}
|
||||||
|
|
||||||
humanize-ms@1.2.1:
|
humanize-ms@1.2.1:
|
||||||
@@ -5588,6 +5855,8 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ini@1.3.8: {}
|
||||||
|
|
||||||
inquirer@8.2.6:
|
inquirer@8.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-escapes: 4.3.2
|
ansi-escapes: 4.3.2
|
||||||
@@ -5776,6 +6045,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kitx@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.20.0
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -5906,6 +6179,12 @@ snapshots:
|
|||||||
pkg-types: 1.3.1
|
pkg-types: 1.3.1
|
||||||
ufo: 1.6.4
|
ufo: 1.6.4
|
||||||
|
|
||||||
|
moment-timezone@0.5.48:
|
||||||
|
dependencies:
|
||||||
|
moment: 2.30.1
|
||||||
|
|
||||||
|
moment@2.30.1: {}
|
||||||
|
|
||||||
ms@2.0.0: {}
|
ms@2.0.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
@@ -6300,7 +6579,7 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
'@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@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:
|
optionalDependencies:
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
luxon: 3.7.2
|
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):
|
rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6697,6 +6977,8 @@ snapshots:
|
|||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
sm3@1.0.3: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -6978,13 +7260,13 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
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:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
picocolors: 1.1.1
|
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:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- less
|
- less
|
||||||
@@ -6996,17 +7278,17 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- 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:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.15
|
postcss: 8.5.15
|
||||||
rollup: 4.62.2
|
rollup: 4.62.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 22.20.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
terser: 5.48.0
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 1.6.1
|
'@vitest/expect': 1.6.1
|
||||||
'@vitest/runner': 1.6.1
|
'@vitest/runner': 1.6.1
|
||||||
@@ -7025,11 +7307,11 @@ snapshots:
|
|||||||
strip-literal: 2.1.1
|
strip-literal: 2.1.1
|
||||||
tinybench: 2.9.0
|
tinybench: 2.9.0
|
||||||
tinypool: 0.8.4
|
tinypool: 0.8.4
|
||||||
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)
|
||||||
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)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 22.20.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- less
|
- less
|
||||||
- lightningcss
|
- lightningcss
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ packages:
|
|||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'server/*'
|
- 'server/*'
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@alicloud/openapi-core': set this to true or false
|
||||||
'@nestjs/core': true
|
'@nestjs/core': true
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
|
|||||||
+40
-27
@@ -1,4 +1,5 @@
|
|||||||
const API = 'http://localhost:3000/api/v1';
|
const API = 'http://localhost:3000/api/v1';
|
||||||
|
const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||||
|
|
||||||
async function req(clientApp, path, options = {}) {
|
async function req(clientApp, path, options = {}) {
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -12,15 +13,27 @@ async function req(clientApp, path, options = {}) {
|
|||||||
return json.data;
|
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() {
|
async function main() {
|
||||||
console.log('1. Health');
|
console.log('1. Health');
|
||||||
await req('USER_H5', '/health');
|
await req('USER_H5', '/health');
|
||||||
|
|
||||||
console.log('2. User login');
|
console.log('2. User login');
|
||||||
const userLogin = await req('USER_H5', '/auth/login/sms', {
|
const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN');
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
|
|
||||||
});
|
|
||||||
const userToken = userLogin.accessToken;
|
const userToken = userLogin.accessToken;
|
||||||
|
|
||||||
console.log('3. Catalog');
|
console.log('3. Catalog');
|
||||||
@@ -48,40 +61,40 @@ async function main() {
|
|||||||
});
|
});
|
||||||
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
|
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 });
|
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
|
||||||
if (!coupons.length) throw new Error('No coupon after pay');
|
if (!coupons.length) throw new Error('No coupons');
|
||||||
|
const tokenRes = await req('USER_H5', '/redeem/tokens', {
|
||||||
console.log('6. Redeem token');
|
|
||||||
const tokenData = await req('USER_H5', '/redeem/tokens', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: userToken,
|
token: userToken,
|
||||||
body: JSON.stringify({ couponId: coupons[0].id, amount: 50 }),
|
body: JSON.stringify({ amount: 50 }),
|
||||||
});
|
});
|
||||||
|
await req('SHOP_H5', '/shop/redeem/preview', {
|
||||||
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', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: shopLogin.accessToken,
|
token: shopLogin.accessToken,
|
||||||
body: JSON.stringify({ token: tokenData.token }),
|
body: JSON.stringify({ token: tokenRes.token }),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('8. Partner orders');
|
console.log('6. Partner login');
|
||||||
const partnerLogin = await req('PARTNER_H5', '/partner/auth/login/sms', {
|
await loginSms(
|
||||||
method: 'POST',
|
'PARTNER_H5',
|
||||||
body: JSON.stringify({ phone: '13700000001', code: '123456' }),
|
'13700000001',
|
||||||
});
|
'PARTNER_LOGIN',
|
||||||
const orders = await req('PARTNER_H5', '/partner/orders', { token: partnerLogin.accessToken });
|
'/partner/auth/login/sms',
|
||||||
if (!orders.list.length) throw new Error('Partner should see orders');
|
'/partner/auth/sms/send',
|
||||||
|
);
|
||||||
|
|
||||||
console.log('\n✅ preV1 smoke passed');
|
console.log('preV1 smoke OK');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
console.error('❌ Smoke failed:', e.message);
|
console.error(e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-11
@@ -24,22 +24,39 @@ async function expectFail(clientApp, path, options = {}) {
|
|||||||
return json.message;
|
return json.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminLogin() {
|
const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||||
return req('HQ_WEB', '/admin/auth/login/sms', {
|
|
||||||
|
async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
|
||||||
|
await req(clientApp, sendPath, {
|
||||||
method: 'POST',
|
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() {
|
async function main() {
|
||||||
console.log('1. Health');
|
console.log('1. Health');
|
||||||
await req('USER_H5', '/health');
|
await req('USER_H5', '/health');
|
||||||
|
|
||||||
console.log('2. User login');
|
console.log('2. User login');
|
||||||
const userLogin = await req('USER_H5', '/auth/login/sms', {
|
const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN');
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
|
|
||||||
});
|
|
||||||
const userToken = userLogin.accessToken;
|
const userToken = userLogin.accessToken;
|
||||||
|
|
||||||
console.log('3. Cities + products');
|
console.log('3. Cities + products');
|
||||||
@@ -105,10 +122,13 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('8. Shop confirm redeem');
|
console.log('8. Shop confirm redeem');
|
||||||
const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', {
|
const shopLogin = await loginSms(
|
||||||
method: 'POST',
|
'SHOP_H5',
|
||||||
body: JSON.stringify({ phone: '13900000001', code: '123456' }),
|
'13900000001',
|
||||||
});
|
'STORE_LOGIN',
|
||||||
|
'/shop/auth/login/sms',
|
||||||
|
'/shop/auth/sms/send',
|
||||||
|
);
|
||||||
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: shopLogin.accessToken,
|
token: shopLogin.accessToken,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# 杜康 API 环境变量模板
|
# 杜康 API 环境变量模板
|
||||||
# 开发:cp .env.development.example .env.development → 本地再 cp 为 .env(改 PORT/DATABASE_URL)
|
# 本地推荐分层:
|
||||||
# 生产:cp .env.production.example .env.production
|
# .env.development — 集成开关与密钥(MOCK_SMS、阿里云、微信)
|
||||||
# 服务器同步:bash deploy/sync-api-env.sh development|production
|
# .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"
|
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
|
||||||
REDIS_URL="redis://localhost:6379"
|
REDIS_URL="redis://localhost:6379"
|
||||||
@@ -10,6 +12,11 @@ JWT_EXPIRES_IN="7d"
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
MOCK_SMS=true
|
MOCK_SMS=true
|
||||||
MOCK_SMS_CODE=123456
|
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_PAY=true
|
||||||
MOCK_DELIVERY_AUTO=true
|
MOCK_DELIVERY_AUTO=true
|
||||||
AUTO_APPROVE_STORE=true
|
AUTO_APPROVE_STORE=true
|
||||||
@@ -30,6 +37,9 @@ WX_API_V3_KEY=
|
|||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
||||||
|
|
||||||
|
# 腾讯位置服务(逆地理编码,微信定位展示城市)
|
||||||
|
TENCENT_LBS_KEY=
|
||||||
|
|
||||||
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||||
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
||||||
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
|
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
|
||||||
@@ -50,5 +60,10 @@ OSS_UPLOAD_PREFIX=uploads
|
|||||||
OSS_UPLOAD_EXPIRE_SECONDS=900
|
OSS_UPLOAD_EXPIRE_SECONDS=900
|
||||||
# 单文件大小上限(字节,默认 10MB)
|
# 单文件大小上限(字节,默认 10MB)
|
||||||
OSS_MAX_UPLOAD_BYTES=10485760
|
OSS_MAX_UPLOAD_BYTES=10485760
|
||||||
# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
|
# 小飞侠同城配送(COURIER_PROVIDER=xiaofeixia)
|
||||||
# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
|
COURIER_PROVIDER=xiaofeixia
|
||||||
|
XIAOFEIXIA_API_URL=https://beta.51xiaoju.cn/app/api/interface.do
|
||||||
|
XIAOFEIXIA_MCH_ID=
|
||||||
|
XIAOFEIXIA_API_KEY=
|
||||||
|
XIAOFEIXIA_SIGN_TYPE=MD5
|
||||||
|
# XIAOFEIXIA_APP_ID=
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ PORT=8090
|
|||||||
|
|
||||||
MOCK_SMS=false
|
MOCK_SMS=false
|
||||||
MOCK_SMS_CODE=
|
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_PAY=false
|
||||||
MOCK_DELIVERY_AUTO=false
|
MOCK_DELIVERY_AUTO=false
|
||||||
AUTO_APPROVE_STORE=false
|
AUTO_APPROVE_STORE=false
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alicloud/dysmsapi20170525": "^4.6.0",
|
||||||
|
"@alicloud/openapi-client": "^0.4.15",
|
||||||
"@dukang/domain": "workspace:*",
|
"@dukang/domain": "workspace:*",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@nestjs/bullmq": "^10.2.0",
|
"@nestjs/bullmq": "^10.2.0",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"bullmq": "^5.12.0",
|
"bullmq": "^5.12.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"ip2region": "^2.3.0",
|
"ip2region": "^2.3.0",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
connection: {
|
connection: {
|
||||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ import type { ICourierProvider } from './courier.types';
|
|||||||
},
|
},
|
||||||
CourierService,
|
CourierService,
|
||||||
],
|
],
|
||||||
exports: [CourierService, COURIER_PROVIDER],
|
exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
|
||||||
})
|
})
|
||||||
export class CourierModule {}
|
export class CourierModule {}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export const PAY_PROVIDER = 'PAY_PROVIDER';
|
|||||||
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||||
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
||||||
export const OSS_PROVIDER = 'OSS_PROVIDER';
|
export const OSS_PROVIDER = 'OSS_PROVIDER';
|
||||||
|
export const MAP_PROVIDER = 'MAP_PROVIDER';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
|
import { SmsCodeStore } from './sms/sms-code.store';
|
||||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||||
|
import { SmsAliyunProvider } from './sms/sms.aliyun.provider';
|
||||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||||
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||||
@@ -9,23 +11,40 @@ import { WechatApiProvider } from './wechat/wechat.api.provider';
|
|||||||
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
||||||
import { OssMockProvider } from './oss/oss.mock.provider';
|
import { OssMockProvider } from './oss/oss.mock.provider';
|
||||||
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
||||||
|
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||||
import {
|
import {
|
||||||
SMS_PROVIDER,
|
SMS_PROVIDER,
|
||||||
PAY_PROVIDER,
|
PAY_PROVIDER,
|
||||||
DELIVERY_PROVIDER,
|
DELIVERY_PROVIDER,
|
||||||
WECHAT_PROVIDER,
|
WECHAT_PROVIDER,
|
||||||
OSS_PROVIDER,
|
OSS_PROVIDER,
|
||||||
|
MAP_PROVIDER,
|
||||||
} from './integrations.constants';
|
} from './integrations.constants';
|
||||||
import { CourierModule } from './courier/courier.module';
|
import { CourierModule } from './courier/courier.module';
|
||||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||||
import type { IWechatProvider } from './wechat/wechat.interface';
|
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||||
import type { IPayProvider } from './pay/pay.interface';
|
import type { IPayProvider } from './pay/pay.interface';
|
||||||
import type { IOssProvider } from './oss/oss.interface';
|
import type { IOssProvider } from './oss/oss.interface';
|
||||||
|
import type { ISmsProvider } from './sms/sms.interface';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||||
providers: [
|
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,
|
WechatApiProvider,
|
||||||
WechatDisabledProvider,
|
WechatDisabledProvider,
|
||||||
{
|
{
|
||||||
@@ -60,9 +79,10 @@ import type { IOssProvider } from './oss/oss.interface';
|
|||||||
},
|
},
|
||||||
inject: [OssMockProvider, OssAliyunProvider],
|
inject: [OssMockProvider, OssAliyunProvider],
|
||||||
},
|
},
|
||||||
SmsMockProvider,
|
|
||||||
DeliveryMockProvider,
|
DeliveryMockProvider,
|
||||||
|
TencentLbsProvider,
|
||||||
|
{ provide: MAP_PROVIDER, useExisting: TencentLbsProvider },
|
||||||
],
|
],
|
||||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
|
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, CourierModule],
|
||||||
})
|
})
|
||||||
export class IntegrationsModule {}
|
export class IntegrationsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import type { WechatActorRef } from '../wechat/wechat-log.util';
|
||||||
|
|
||||||
|
export type ReverseGeocodeResult = {
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
logId: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCityName(name: string) {
|
||||||
|
return name.replace(/市$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TencentLbsProvider {
|
||||||
|
private readonly logger = new Logger(TencentLbsProvider.name);
|
||||||
|
private readonly config = loadAppConfig();
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return !!this.config.tencentLbsKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reverseGeocode(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
actorRef?: WechatActorRef,
|
||||||
|
): Promise<ReverseGeocodeResult | null> {
|
||||||
|
const baseLog = {
|
||||||
|
provider: 'WECHAT_MAP' as const,
|
||||||
|
scene: 'REVERSE_GEOCODE',
|
||||||
|
refType: actorRef?.refType,
|
||||||
|
refId: actorRef?.refId,
|
||||||
|
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||||
|
requestBody: {
|
||||||
|
latitude: Number(latitude.toFixed(6)),
|
||||||
|
longitude: Number(longitude.toFixed(6)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
const log = await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
...baseLog,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: 'TENCENT_LBS_KEY 未配置',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.warn('Tencent LBS key missing, skip reverse geocode');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = `${latitude},${longitude}`;
|
||||||
|
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
|
||||||
|
url.searchParams.set('location', location);
|
||||||
|
url.searchParams.set('key', this.config.tencentLbsKey);
|
||||||
|
url.searchParams.set('get_poi', '0');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
status?: number;
|
||||||
|
message?: string;
|
||||||
|
result?: {
|
||||||
|
ad_info?: {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ad = data.result?.ad_info;
|
||||||
|
const ok = data.status === 0 && !!ad?.city;
|
||||||
|
const responseBody = {
|
||||||
|
status: data.status,
|
||||||
|
message: data.message,
|
||||||
|
province: ad?.province,
|
||||||
|
city: ad?.city,
|
||||||
|
district: ad?.district,
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
...baseLog,
|
||||||
|
responseBody,
|
||||||
|
status: ok ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: ok ? undefined : data.message ?? '逆地理编码失败',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok || !ad?.province || !ad?.city) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
province: ad.province,
|
||||||
|
city: normalizeCityName(ad.city),
|
||||||
|
district: ad.district ?? '',
|
||||||
|
logId: log.id,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(`Tencent LBS reverse geocode failed: ${message}`);
|
||||||
|
await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
...baseLog,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: message.slice(0, 512),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,124 @@
|
|||||||
|
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 type { ISmsProvider, SmsActorRef, SmsSendResult } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(phone: string) {
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||||
|
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||||
|
const masked = maskPhone(phone);
|
||||||
|
const request = new SendSmsRequest({
|
||||||
|
phoneNumbers: phone,
|
||||||
|
signName: this.config.aliyunSmsSignName,
|
||||||
|
templateCode: this.config.aliyunSmsTemplateCode,
|
||||||
|
templateParam: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseLog = {
|
||||||
|
provider: 'SMS' as const,
|
||||||
|
scene,
|
||||||
|
refType: actorRef?.refType,
|
||||||
|
refId: actorRef?.refId,
|
||||||
|
requestBody: {
|
||||||
|
phone: masked,
|
||||||
|
templateCode: this.config.aliyunSmsTemplateCode,
|
||||||
|
signName: this.config.aliyunSmsSignName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
const log = await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
...baseLog,
|
||||||
|
responseBody,
|
||||||
|
externalNo: bizId,
|
||||||
|
status: ok ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
this.logger.log(`Aliyun SMS sent to ${masked} scene=${scene} bizId=${bizId ?? '-'}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Aliyun SMS failed for ${masked} scene=${scene}: ${response.body?.message}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
logId: log.id,
|
||||||
|
ok,
|
||||||
|
errorMessage: ok ? undefined : response.body?.message ?? '短信发送失败',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(`Aliyun SMS send failed: ${message}`);
|
||||||
|
const log = await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
...baseLog,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: message.slice(0, 512),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { logId: log.id, ok: false, errorMessage: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||||
|
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
|
export type SmsActorRef = {
|
||||||
|
refType: string;
|
||||||
|
refId: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmsSendResult = {
|
||||||
|
logId: bigint;
|
||||||
|
ok: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ISmsProvider {
|
export interface ISmsProvider {
|
||||||
send(phone: string, scene: string): Promise<void>;
|
send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult>;
|
||||||
verify(phone: string, code: string, scene: string): Promise<void>;
|
verify(phone: string, code: string, scene: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,41 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { ISmsProvider } from './sms.interface';
|
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||||
|
import { SmsCodeStore } from './sms-code.store';
|
||||||
|
|
||||||
|
function maskPhone(phone: string) {
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsMockProvider implements ISmsProvider {
|
export class SmsMockProvider implements ISmsProvider {
|
||||||
private readonly config = loadAppConfig();
|
private readonly logger = new Logger(SmsMockProvider.name);
|
||||||
|
|
||||||
async send(_phone: string, _scene: string): Promise<void> {
|
constructor(
|
||||||
if (!this.config.mockSms) {
|
private readonly smsCodeStore: SmsCodeStore,
|
||||||
throw new Error('Real SMS not implemented in preV1');
|
private readonly prisma: PrismaService,
|
||||||
}
|
) {}
|
||||||
|
|
||||||
|
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||||
|
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||||
|
const masked = maskPhone(phone);
|
||||||
|
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||||
|
|
||||||
|
const log = await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'SMS',
|
||||||
|
scene,
|
||||||
|
refType: actorRef?.refType,
|
||||||
|
refId: actorRef?.refId,
|
||||||
|
requestBody: { phone: masked, mode: 'MOCK', scene },
|
||||||
|
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
|
||||||
|
status: 'SUCCESS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { logId: log.id, ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(phone: string, code: string, _scene: string): Promise<void> {
|
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||||
if (this.config.mockSms && code === this.config.mockSmsCode) {
|
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(`Invalid verification code for ${phone}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
|
||||||
|
export type WechatActorRef = {
|
||||||
|
refType: string;
|
||||||
|
refId: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogWechatAuthInput = {
|
||||||
|
scene: string;
|
||||||
|
requestUrl?: string;
|
||||||
|
requestBody?: Record<string, unknown>;
|
||||||
|
responseBody?: Record<string, unknown>;
|
||||||
|
externalNo?: string;
|
||||||
|
status: 'SUCCESS' | 'FAILED';
|
||||||
|
errorMessage?: string;
|
||||||
|
actorRef?: WechatActorRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function logWechatAuth(prisma: PrismaService, input: LogWechatAuthInput) {
|
||||||
|
const row = await prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'WECHAT_AUTH',
|
||||||
|
scene: input.scene,
|
||||||
|
refType: input.actorRef?.refType,
|
||||||
|
refId: input.actorRef?.refId,
|
||||||
|
requestUrl: input.requestUrl?.slice(0, 512),
|
||||||
|
requestBody: input.requestBody as never,
|
||||||
|
responseBody: input.responseBody as never,
|
||||||
|
externalNo: input.externalNo,
|
||||||
|
status: input.status,
|
||||||
|
errorMessage: input.errorMessage?.slice(0, 512),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } fro
|
|||||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
||||||
|
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||||
import {
|
import {
|
||||||
decryptPayResource,
|
decryptPayResource,
|
||||||
verifyPaySignature,
|
verifyPaySignature,
|
||||||
@@ -28,7 +30,10 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||||
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
|
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
|
||||||
|
|
||||||
constructor(private readonly redis: RedisService) {}
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
||||||
@@ -60,19 +65,37 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`;
|
return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async code2Session(code: string): Promise<WechatCodeSession> {
|
async code2Session(code: string, actorRef?: WechatActorRef): Promise<WechatCodeSession> {
|
||||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||||
url.searchParams.set('appid', this.appId);
|
url.searchParams.set('appid', this.appId);
|
||||||
url.searchParams.set('secret', this.appSecret);
|
url.searchParams.set('secret', '***');
|
||||||
url.searchParams.set('js_code', code);
|
url.searchParams.set('js_code', code);
|
||||||
url.searchParams.set('grant_type', 'authorization_code');
|
url.searchParams.set('grant_type', 'authorization_code');
|
||||||
|
const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||||
|
apiUrl.searchParams.set('appid', this.appId);
|
||||||
|
apiUrl.searchParams.set('secret', this.appSecret);
|
||||||
|
apiUrl.searchParams.set('js_code', code);
|
||||||
|
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
||||||
const data = await this.fetchJson<{
|
const data = await this.fetchJson<{
|
||||||
openid?: string;
|
openid?: string;
|
||||||
unionid?: string;
|
unionid?: string;
|
||||||
session_key?: string;
|
session_key?: string;
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
errmsg?: string;
|
errmsg?: string;
|
||||||
}>(url.toString());
|
}>(apiUrl.toString());
|
||||||
|
const ok = !!data.openid;
|
||||||
|
await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'LOGIN',
|
||||||
|
requestUrl: url.toString(),
|
||||||
|
requestBody: { grant_type: 'authorization_code', platform: 'mini' },
|
||||||
|
responseBody: ok
|
||||||
|
? { openid: data.openid, unionid: data.unionid }
|
||||||
|
: { errcode: data.errcode, errmsg: data.errmsg },
|
||||||
|
externalNo: data.openid,
|
||||||
|
status: ok ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: ok ? undefined : data.errmsg || '微信 code2session 失败',
|
||||||
|
actorRef,
|
||||||
|
});
|
||||||
if (!data.openid) {
|
if (!data.openid) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
|
throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
|
||||||
}
|
}
|
||||||
@@ -83,12 +106,17 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
|
async oauth2AccessToken(code: string, actorRef?: WechatActorRef): Promise<WechatOAuthSession> {
|
||||||
const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
const maskedUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||||
url.searchParams.set('appid', this.appId);
|
maskedUrl.searchParams.set('appid', this.appId);
|
||||||
url.searchParams.set('secret', this.appSecret);
|
maskedUrl.searchParams.set('secret', '***');
|
||||||
url.searchParams.set('code', code);
|
maskedUrl.searchParams.set('code', code);
|
||||||
url.searchParams.set('grant_type', 'authorization_code');
|
maskedUrl.searchParams.set('grant_type', 'authorization_code');
|
||||||
|
const apiUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||||
|
apiUrl.searchParams.set('appid', this.appId);
|
||||||
|
apiUrl.searchParams.set('secret', this.appSecret);
|
||||||
|
apiUrl.searchParams.set('code', code);
|
||||||
|
apiUrl.searchParams.set('grant_type', 'authorization_code');
|
||||||
const data = await this.fetchJson<{
|
const data = await this.fetchJson<{
|
||||||
openid?: string;
|
openid?: string;
|
||||||
unionid?: string;
|
unionid?: string;
|
||||||
@@ -96,7 +124,20 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
errmsg?: string;
|
errmsg?: string;
|
||||||
}>(url.toString());
|
}>(apiUrl.toString());
|
||||||
|
const ok = !!data.openid;
|
||||||
|
await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'LOGIN',
|
||||||
|
requestUrl: maskedUrl.toString(),
|
||||||
|
requestBody: { grant_type: 'authorization_code', platform: 'h5' },
|
||||||
|
responseBody: ok
|
||||||
|
? { openid: data.openid, unionid: data.unionid }
|
||||||
|
: { errcode: data.errcode, errmsg: data.errmsg },
|
||||||
|
externalNo: data.openid,
|
||||||
|
status: ok ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: ok ? undefined : data.errmsg || '微信 OAuth 失败',
|
||||||
|
actorRef,
|
||||||
|
});
|
||||||
if (!data.openid) {
|
if (!data.openid) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
|
throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
|
||||||
}
|
}
|
||||||
@@ -108,37 +149,69 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createJssdkConfig(url: string) {
|
async createJssdkConfig(url: string, actorRef?: WechatActorRef) {
|
||||||
const ticket = await this.getJsapiTicket();
|
try {
|
||||||
const nonceStr = randomBytes(8).toString('hex');
|
const ticket = await this.getJsapiTicket();
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
const nonceStr = randomBytes(8).toString('hex');
|
||||||
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
const signature = createHash('sha1').update(raw).digest('hex');
|
const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
|
||||||
return {
|
const signature = createHash('sha1').update(raw).digest('hex');
|
||||||
appId: this.appId,
|
const config = {
|
||||||
timestamp,
|
appId: this.appId,
|
||||||
nonceStr,
|
timestamp,
|
||||||
signature,
|
nonceStr,
|
||||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
signature,
|
||||||
};
|
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
||||||
|
};
|
||||||
|
await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'JSSDK_CONFIG',
|
||||||
|
requestUrl: url.split('#')[0],
|
||||||
|
requestBody: { appId: this.appId },
|
||||||
|
responseBody: { appId: this.appId, timestamp, nonceStr },
|
||||||
|
status: 'SUCCESS',
|
||||||
|
actorRef,
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'JSSDK_CONFIG',
|
||||||
|
requestUrl: url.split('#')[0],
|
||||||
|
requestBody: { appId: this.appId },
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: message,
|
||||||
|
actorRef,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string> {
|
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
|
||||||
if (platform === 'h5') {
|
if (platform === 'h5') {
|
||||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||||
}
|
}
|
||||||
const accessToken = await this.getAccessToken();
|
const accessToken = await this.getAccessToken();
|
||||||
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||||
const data = await this.fetchJson<{
|
const data = await this.fetchJson<{
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
errmsg?: string;
|
errmsg?: string;
|
||||||
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
|
phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
|
||||||
}>(url, {
|
}>(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code }),
|
||||||
});
|
});
|
||||||
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
|
const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
|
||||||
|
const ok = !!phone;
|
||||||
|
await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'BIND_PHONE',
|
||||||
|
requestUrl: 'https://api.weixin.qq.com/wxa/business/getuserphonenumber',
|
||||||
|
requestBody: { platform },
|
||||||
|
responseBody: ok ? { phone: `${phone!.slice(0, 3)}****${phone!.slice(-4)}` } : { errcode: data.errcode, errmsg: data.errmsg },
|
||||||
|
status: ok ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: ok ? undefined : data.errmsg || '获取手机号失败',
|
||||||
|
actorRef,
|
||||||
|
});
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,23 @@ export interface IWechatProvider {
|
|||||||
getMchId(): string;
|
getMchId(): string;
|
||||||
|
|
||||||
/** 小程序 code2session */
|
/** 小程序 code2session */
|
||||||
code2Session(code: string): Promise<WechatCodeSession>;
|
code2Session(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatCodeSession>;
|
||||||
|
|
||||||
/** 公众号 H5 OAuth code 换 openId */
|
/** 公众号 H5 OAuth code 换 openId */
|
||||||
oauth2AccessToken(code: string): Promise<WechatOAuthSession>;
|
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>;
|
||||||
|
|
||||||
/** JSSDK 签名配置 */
|
/** JSSDK 签名配置 */
|
||||||
createJssdkConfig(url: string): Promise<WechatJssdkConfig>;
|
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>;
|
||||||
|
|
||||||
/** 构建公众号 OAuth 授权 URL */
|
/** 构建公众号 OAuth 授权 URL */
|
||||||
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
||||||
|
|
||||||
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
||||||
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
|
getPhoneNumberByCode(
|
||||||
|
code: string,
|
||||||
|
platform: 'mini' | 'h5',
|
||||||
|
actorRef?: { refType: string; refId: bigint },
|
||||||
|
): Promise<string>;
|
||||||
|
|
||||||
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
|
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
|
||||||
createJsapiPrepay(params: {
|
createJsapiPrepay(params: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import './load-env';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
@@ -24,6 +26,9 @@ async function bootstrap() {
|
|||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||||
const port = process.env.PORT || 3000;
|
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);
|
await app.listen(port);
|
||||||
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
|
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 { IamModule } from '../iam/iam.module';
|
||||||
import { AnalyticsController } from './analytics.controller';
|
import { AnalyticsController } from './analytics.controller';
|
||||||
import { AnalyticsService } from './analytics.service';
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule],
|
imports: [forwardRef(() => IamModule)],
|
||||||
controllers: [AnalyticsController],
|
controllers: [AnalyticsController],
|
||||||
providers: [AnalyticsService],
|
providers: [AnalyticsService],
|
||||||
|
exports: [AnalyticsService],
|
||||||
})
|
})
|
||||||
export class AnalyticsModule {}
|
export class AnalyticsModule {}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import type { ClientApp } from '@prisma/client';
|
import type { ClientApp } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
|
||||||
|
export type TrackEventInput = {
|
||||||
|
eventName: string;
|
||||||
|
pagePath?: string;
|
||||||
|
refType?: string;
|
||||||
|
refId?: bigint;
|
||||||
|
extraJson?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AnalyticsService {
|
export class AnalyticsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -13,13 +21,36 @@ export class AnalyticsService {
|
|||||||
) {
|
) {
|
||||||
if (!events?.length) return { count: 0 };
|
if (!events?.length) return { count: 0 };
|
||||||
await this.prisma.logUserAnalytics.createMany({
|
await this.prisma.logUserAnalytics.createMany({
|
||||||
data: events.map((e) => ({
|
data: events.map((e) => this.toRow(userId, clientApp, {
|
||||||
userId,
|
|
||||||
eventName: e.eventName,
|
eventName: e.eventName,
|
||||||
extraJson: e.params as never,
|
extraJson: e.params,
|
||||||
clientApp: clientApp as ClientApp,
|
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 };
|
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 {
|
return {
|
||||||
mockPay: cfg.mockPay,
|
mockPay: cfg.mockPay,
|
||||||
wechatPayEnabled: cfg.wechatPayEnabled,
|
wechatPayEnabled: cfg.wechatPayEnabled,
|
||||||
|
mockSms: cfg.mockSms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { ResourceService } from './resource.service';
|
import { ResourceService } from './resource.service';
|
||||||
import { EventService } from './event.service';
|
import { EventService } from './event.service';
|
||||||
@@ -11,9 +12,10 @@ import { TicketController } from './ticket.controller';
|
|||||||
import { ThirdPartyLogController } from './third-party-log.controller';
|
import { ThirdPartyLogController } from './third-party-log.controller';
|
||||||
import { WechatController } from './wechat.controller';
|
import { WechatController } from './wechat.controller';
|
||||||
import { ClientConfigController } from './client-config.controller';
|
import { ClientConfigController } from './client-config.controller';
|
||||||
|
import { WechatLocationService } from './wechat-location.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, IntegrationsModule],
|
imports: [IamModule, IntegrationsModule, forwardRef(() => AnalyticsModule)],
|
||||||
controllers: [
|
controllers: [
|
||||||
ResourceController,
|
ResourceController,
|
||||||
EventController,
|
EventController,
|
||||||
@@ -22,7 +24,7 @@ import { ClientConfigController } from './client-config.controller';
|
|||||||
WechatController,
|
WechatController,
|
||||||
ClientConfigController,
|
ClientConfigController,
|
||||||
],
|
],
|
||||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService],
|
||||||
exports: [ResourceService, EventService, TicketService],
|
exports: [ResourceService, EventService, TicketService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
export class CommonModule {}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||||
|
import { logWechatAuth, type WechatActorRef } from '../../integrations/wechat/wechat-log.util';
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
|
|
||||||
|
export type ReportWechatLocationInput = {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
sdk: 'jssdk' | 'geolocation';
|
||||||
|
status: 'success' | 'fail';
|
||||||
|
errMsg?: string;
|
||||||
|
clientApp?: string;
|
||||||
|
userId?: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportWechatLocationResult = {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
cityName?: string;
|
||||||
|
openCity: boolean;
|
||||||
|
thirdPartyLogIds: {
|
||||||
|
location?: string;
|
||||||
|
geocode?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCityName(name: string) {
|
||||||
|
return name.replace(/市$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchOpenCity(
|
||||||
|
cities: Array<{ code: string; name: string; province: string }>,
|
||||||
|
province: string,
|
||||||
|
city: string,
|
||||||
|
) {
|
||||||
|
const cityNorm = normalizeCityName(city);
|
||||||
|
return cities.find((c) => {
|
||||||
|
const nameNorm = normalizeCityName(c.name);
|
||||||
|
if (nameNorm !== cityNorm && c.name !== city && c.name !== `${cityNorm}市`) return false;
|
||||||
|
if (c.province && province && c.province !== province) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WechatLocationService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly tencentLbs: TencentLbsProvider,
|
||||||
|
private readonly analyticsService: AnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async reportLocation(input: ReportWechatLocationInput): Promise<ReportWechatLocationResult> {
|
||||||
|
const actorRef: WechatActorRef | undefined = input.userId
|
||||||
|
? { refType: 'USER', refId: input.userId }
|
||||||
|
: undefined;
|
||||||
|
const clientApp = (input.clientApp as ClientApp) || ClientApp.USER_H5;
|
||||||
|
const thirdPartyLogIds: ReportWechatLocationResult['thirdPartyLogIds'] = {};
|
||||||
|
|
||||||
|
const locationLogId = await logWechatAuth(this.prisma, {
|
||||||
|
scene: 'GET_LOCATION',
|
||||||
|
requestBody: {
|
||||||
|
sdk: input.sdk,
|
||||||
|
status: input.status,
|
||||||
|
...(input.latitude != null && input.longitude != null
|
||||||
|
? {
|
||||||
|
latitude: Number(input.latitude.toFixed(3)),
|
||||||
|
longitude: Number(input.longitude.toFixed(3)),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
|
||||||
|
},
|
||||||
|
responseBody: { reported: true },
|
||||||
|
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
|
||||||
|
actorRef,
|
||||||
|
});
|
||||||
|
thirdPartyLogIds.location = locationLogId.toString();
|
||||||
|
|
||||||
|
if (input.status !== 'success' || input.latitude == null || input.longitude == null) {
|
||||||
|
return { openCity: false, thirdPartyLogIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = await this.tencentLbs.reverseGeocode(input.latitude, input.longitude, actorRef);
|
||||||
|
if (geo) {
|
||||||
|
thirdPartyLogIds.geocode = geo.logId.toString();
|
||||||
|
}
|
||||||
|
if (!geo) {
|
||||||
|
return { openCity: false, thirdPartyLogIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCities = await this.prisma.commonCity.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
select: { code: true, name: true, province: true },
|
||||||
|
});
|
||||||
|
const matched = matchOpenCity(openCities, geo.province, geo.city);
|
||||||
|
|
||||||
|
const result: ReportWechatLocationResult = {
|
||||||
|
province: geo.province,
|
||||||
|
city: geo.city,
|
||||||
|
district: geo.district,
|
||||||
|
cityCode: matched?.code,
|
||||||
|
cityName: matched?.name ?? `${geo.city}市`,
|
||||||
|
openCity: !!matched,
|
||||||
|
thirdPartyLogIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.userId) {
|
||||||
|
const mapLogId = geo.logId;
|
||||||
|
this.analyticsService.trackOneSafe(input.userId, clientApp, {
|
||||||
|
eventName: 'wechat_location',
|
||||||
|
refType: 'THIRD_PARTY_LOG',
|
||||||
|
refId: mapLogId,
|
||||||
|
extraJson: {
|
||||||
|
sdk: input.sdk,
|
||||||
|
province: geo.province,
|
||||||
|
city: geo.city,
|
||||||
|
district: geo.district,
|
||||||
|
openCity: !!matched,
|
||||||
|
cityCode: matched?.code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Inject, Post, Query } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Inject, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { WechatLocationService } from './wechat-location.service';
|
||||||
|
|
||||||
class PhoneNumberDto {
|
class PhoneNumberDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -14,15 +19,43 @@ class PhoneNumberDto {
|
|||||||
platform?: 'mini' | 'h5';
|
platform?: 'mini' | 'h5';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WechatLocationDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['jssdk', 'geolocation'])
|
||||||
|
sdk: 'jssdk' | 'geolocation';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['success', 'fail'])
|
||||||
|
status: 'success' | 'fail';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
errMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('common/wechat')
|
@Controller('common/wechat')
|
||||||
export class WechatController {
|
export class WechatController {
|
||||||
constructor(@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider) {}
|
constructor(
|
||||||
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
|
private readonly locationService: WechatLocationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('jssdk-config')
|
@Get('jssdk-config')
|
||||||
async jssdkConfig(@Query('url') url: string) {
|
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
|
||||||
if (!url) throw new BadRequestException('url 参数必填');
|
if (!url) throw new BadRequestException('url 参数必填');
|
||||||
const pageUrl = decodeURIComponent(url).split('#')[0];
|
const pageUrl = decodeURIComponent(url).split('#')[0];
|
||||||
return this.wechat.createJssdkConfig(pageUrl);
|
const user = (req as Request & { user?: AuthUser }).user;
|
||||||
|
const actorRef =
|
||||||
|
user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined;
|
||||||
|
return this.wechat.createJssdkConfig(pageUrl, actorRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('oauth-url')
|
@Get('oauth-url')
|
||||||
@@ -41,4 +74,21 @@ export class WechatController {
|
|||||||
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
|
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
|
||||||
.then((phone) => ({ phone }));
|
.then((phone) => ({ phone }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('location')
|
||||||
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
|
reportLocation(@Req() req: Request, @Body() dto: WechatLocationDto) {
|
||||||
|
const user = (req as Request & { user?: AuthUser }).user;
|
||||||
|
const userId = user?.actorType === 'USER' ? user.actorId : undefined;
|
||||||
|
const clientApp = (req.headers['x-client-app'] as string) || ClientApp.USER_H5;
|
||||||
|
return this.locationService.reportLocation({
|
||||||
|
latitude: dto.latitude,
|
||||||
|
longitude: dto.longitude,
|
||||||
|
sdk: dto.sdk,
|
||||||
|
status: dto.status,
|
||||||
|
errMsg: dto.errMsg,
|
||||||
|
clientApp,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AdminAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.HQ_WEB });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/sms')
|
@Post('login/sms')
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ export class UserAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/sms/send')
|
@Post('auth/sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
sendSms(@Req() req: Request, @Body() dto: SendSmsDto) {
|
||||||
|
const guest = (req as Request & { user?: AuthUser }).user;
|
||||||
|
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||||
|
return this.authService.sendSms(dto.phone, dto.scene, {
|
||||||
|
guestUserId: guestId,
|
||||||
|
clientApp: ClientApp.USER_H5,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/login/sms')
|
@Post('auth/login/sms')
|
||||||
@@ -85,7 +91,7 @@ export class ShopAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.SHOP_H5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/sms')
|
@Post('login/sms')
|
||||||
@@ -105,7 +111,7 @@ export class PartnerAuthController {
|
|||||||
|
|
||||||
@Post('sms/send')
|
@Post('sms/send')
|
||||||
sendSms(@Body() dto: SendSmsDto) {
|
sendSms(@Body() dto: SendSmsDto) {
|
||||||
return this.authService.sendSms(dto.phone, dto.scene);
|
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.PARTNER_H5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/sms')
|
@Post('login/sms')
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
|||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||||
|
import type { SmsActorRef } from '../../integrations/sms/sms.interface';
|
||||||
|
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
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';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
@@ -53,10 +57,159 @@ export class AuthService {
|
|||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
||||||
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
|
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
|
||||||
|
private readonly analyticsService: AnalyticsService,
|
||||||
|
private readonly smsCodeStore: SmsCodeStore,
|
||||||
|
private readonly userAddressService: UserAddressService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendSms(phone: string, scene: string) {
|
private assertMobilePhone(phone: string) {
|
||||||
await this.smsProvider.send(phone, scene);
|
const trimmed = phone.trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
|
||||||
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskPhone(phone: string) {
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clientAppForScene(scene: string): ClientApp {
|
||||||
|
switch (scene) {
|
||||||
|
case SmsScene.STORE_LOGIN:
|
||||||
|
return ClientApp.SHOP_H5;
|
||||||
|
case SmsScene.PARTNER_LOGIN:
|
||||||
|
case SmsScene.PARTNER_STAFF_ADD:
|
||||||
|
return ClientApp.PARTNER_H5;
|
||||||
|
case SmsScene.HQ_LOGIN:
|
||||||
|
return ClientApp.HQ_WEB;
|
||||||
|
default:
|
||||||
|
return ClientApp.USER_H5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSmsActorRef(
|
||||||
|
phone: string,
|
||||||
|
scene: string,
|
||||||
|
guestUserId?: bigint,
|
||||||
|
): Promise<SmsActorRef | undefined> {
|
||||||
|
switch (scene) {
|
||||||
|
case SmsScene.USER_LOGIN:
|
||||||
|
case SmsScene.BIND_PHONE: {
|
||||||
|
if (guestUserId) return { refType: 'USER', refId: guestUserId };
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return user ? { refType: 'USER', refId: user.id } : undefined;
|
||||||
|
}
|
||||||
|
case SmsScene.STORE_LOGIN: {
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return account ? { refType: 'STORE', refId: account.id } : undefined;
|
||||||
|
}
|
||||||
|
case SmsScene.PARTNER_LOGIN:
|
||||||
|
case SmsScene.PARTNER_STAFF_ADD: {
|
||||||
|
const account = await this.prisma.partnerAccount.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return account ? { refType: 'PARTNER', refId: account.id } : undefined;
|
||||||
|
}
|
||||||
|
case SmsScene.HQ_LOGIN: {
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return account ? { refType: 'HQ', refId: account.id } : undefined;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackSmsUserEvent(
|
||||||
|
userId: bigint | undefined,
|
||||||
|
clientApp: ClientApp | string,
|
||||||
|
eventName: string,
|
||||||
|
extraJson: Record<string, unknown>,
|
||||||
|
thirdPartyLogId?: bigint,
|
||||||
|
) {
|
||||||
|
if (!userId) return;
|
||||||
|
this.analyticsService.trackOneSafe(userId, clientApp, {
|
||||||
|
eventName,
|
||||||
|
refType: thirdPartyLogId ? 'THIRD_PARTY_LOG' : undefined,
|
||||||
|
refId: thirdPartyLogId,
|
||||||
|
extraJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifySmsForUser(
|
||||||
|
phone: string,
|
||||||
|
code: string,
|
||||||
|
scene: SmsScene.USER_LOGIN | SmsScene.BIND_PHONE,
|
||||||
|
clientApp: ClientApp,
|
||||||
|
userId?: bigint,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.smsProvider.verify(phone, code, scene);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BadRequestException) {
|
||||||
|
this.trackSmsUserEvent(userId, clientApp, 'sms_verify_fail', {
|
||||||
|
scene,
|
||||||
|
phone: this.maskPhone(phone),
|
||||||
|
reason: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(
|
||||||
|
phone: string,
|
||||||
|
scene: string,
|
||||||
|
opts?: { guestUserId?: bigint; clientApp?: ClientApp },
|
||||||
|
) {
|
||||||
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
|
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
|
||||||
|
throw new BadRequestException('无效的验证码场景');
|
||||||
|
}
|
||||||
|
const clientApp = opts?.clientApp ?? this.clientAppForScene(scene);
|
||||||
|
const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId);
|
||||||
|
const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId;
|
||||||
|
|
||||||
|
await this.smsCodeStore.assertSendCooldown(normalizedPhone);
|
||||||
|
try {
|
||||||
|
const result = await this.smsProvider.send(normalizedPhone, scene, actorRef);
|
||||||
|
await this.smsCodeStore.setSendCooldown(normalizedPhone);
|
||||||
|
this.trackSmsUserEvent(
|
||||||
|
userId,
|
||||||
|
clientApp,
|
||||||
|
'sms_send',
|
||||||
|
{
|
||||||
|
scene,
|
||||||
|
phone: this.maskPhone(normalizedPhone),
|
||||||
|
status: result.ok ? 'success' : 'failed',
|
||||||
|
...(result.errorMessage ? { message: result.errorMessage } : {}),
|
||||||
|
},
|
||||||
|
result.logId,
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new BadRequestException(result.errorMessage ?? '短信发送失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BadRequestException) throw err;
|
||||||
|
const message = err instanceof Error ? err.message : '短信发送失败';
|
||||||
|
this.trackSmsUserEvent(userId, clientApp, 'sms_send', {
|
||||||
|
scene,
|
||||||
|
phone: this.maskPhone(normalizedPhone),
|
||||||
|
status: 'failed',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
return { sent: true };
|
return { sent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +264,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||||
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { phone: normalizedPhone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await this.verifySmsForUser(
|
||||||
|
normalizedPhone,
|
||||||
|
code,
|
||||||
|
SmsScene.USER_LOGIN,
|
||||||
|
clientApp,
|
||||||
|
guestId ?? existingUser?.id,
|
||||||
|
);
|
||||||
let user: UserRow | null = await this.prisma.user.findUnique({
|
let user: UserRow | null = await this.prisma.user.findUnique({
|
||||||
where: { phone },
|
where: { phone: normalizedPhone },
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,9 +289,9 @@ export class AuthService {
|
|||||||
user = await this.prisma.user.update({
|
user = await this.prisma.user.update({
|
||||||
where: { id: guestId },
|
where: { id: guestId },
|
||||||
data: {
|
data: {
|
||||||
phone,
|
phone: normalizedPhone,
|
||||||
phoneVerifiedAt: new Date(),
|
phoneVerifiedAt: new Date(),
|
||||||
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
|
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
|
||||||
},
|
},
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
});
|
});
|
||||||
@@ -139,10 +303,10 @@ export class AuthService {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
user = await this.prisma.user.create({
|
user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
phone,
|
phone: normalizedPhone,
|
||||||
phoneVerifiedAt: new Date(),
|
phoneVerifiedAt: new Date(),
|
||||||
userNo: generateUserNo(),
|
userNo: generateUserNo(),
|
||||||
nickname: `用户${phone.slice(-4)}`,
|
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||||||
cityPreference: {
|
cityPreference: {
|
||||||
create: {
|
create: {
|
||||||
selectedCityCode: '410100',
|
selectedCityCode: '410100',
|
||||||
@@ -170,30 +334,40 @@ export class AuthService {
|
|||||||
|
|
||||||
if (!user) throw new BadRequestException('登录失败');
|
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);
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
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.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
|
||||||
const guest = await this.assertActiveUser(actorId);
|
const guest = await this.assertActiveUser(actorId);
|
||||||
|
|
||||||
if (guest.phone && guest.phoneVerifiedAt) {
|
if (guest.phone && guest.phoneVerifiedAt) {
|
||||||
if (guest.phone === phone) {
|
if (guest.phone === normalizedPhone) {
|
||||||
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
|
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
|
||||||
}
|
}
|
||||||
throw new BadRequestException('当前账号已绑定其他手机号');
|
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;
|
let targetUser: UserRow;
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
targetUser = await this.prisma.user.update({
|
targetUser = await this.prisma.user.update({
|
||||||
where: { id: guest.id },
|
where: { id: guest.id },
|
||||||
data: {
|
data: {
|
||||||
phone,
|
phone: normalizedPhone,
|
||||||
phoneVerifiedAt: new Date(),
|
phoneVerifiedAt: new Date(),
|
||||||
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
|
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
|
||||||
},
|
},
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
});
|
});
|
||||||
@@ -206,13 +380,18 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.trackSmsUserEvent(targetUser.id, clientApp, 'bind_phone', {
|
||||||
|
phone: this.maskPhone(normalizedPhone),
|
||||||
|
});
|
||||||
|
|
||||||
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
|
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
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({
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
where: { phone },
|
where: { phone: normalizedPhone },
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
});
|
});
|
||||||
if (!account) throw new BadRequestException('门店账号不存在');
|
if (!account) throw new BadRequestException('门店账号不存在');
|
||||||
@@ -230,9 +409,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
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({
|
const account = await this.prisma.partnerAccount.findUnique({
|
||||||
where: { phone },
|
where: { phone: normalizedPhone },
|
||||||
include: { partner: true },
|
include: { partner: true },
|
||||||
});
|
});
|
||||||
if (!account) throw new BadRequestException('合伙人账号不存在');
|
if (!account) throw new BadRequestException('合伙人账号不存在');
|
||||||
@@ -251,8 +431,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginHq(phone: string, code: string, clientApp: ClientApp) {
|
async loginHq(phone: string, code: string, clientApp: ClientApp) {
|
||||||
await this.smsProvider.verify(phone, code, SmsScene.HQ_LOGIN);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
const account = await this.prisma.hqAccount.findUnique({ where: { 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) throw new BadRequestException('HQ账号不存在');
|
||||||
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
|
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
|
||||||
await this.prisma.hqAccount.update({
|
await this.prisma.hqAccount.update({
|
||||||
@@ -331,6 +512,14 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
include: { avatar: true },
|
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);
|
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,12 +561,12 @@ export class AuthService {
|
|||||||
clientApp: ClientApp,
|
clientApp: ClientApp,
|
||||||
) {
|
) {
|
||||||
this.assertWechatEnabled();
|
this.assertWechatEnabled();
|
||||||
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
|
|
||||||
|
|
||||||
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
|
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
|
||||||
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
|
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
|
||||||
|
|
||||||
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
|
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
|
||||||
|
await this.verifySmsForUser(phone, code, SmsScene.BIND_PHONE, clientApp, guestId);
|
||||||
const wxUser = await this.prisma.user.findFirst({
|
const wxUser = await this.prisma.user.findFirst({
|
||||||
where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
|
where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
@@ -460,6 +649,18 @@ export class AuthService {
|
|||||||
if (!targetUserId) throw new BadRequestException('绑定失败');
|
if (!targetUserId) throw new BadRequestException('绑定失败');
|
||||||
const user = await this.assertActiveUser(targetUserId);
|
const user = await this.assertActiveUser(targetUserId);
|
||||||
await this.redis.del(`wx:session:${wxSessionKey}`);
|
await this.redis.del(`wx:session:${wxSessionKey}`);
|
||||||
|
this.trackSmsUserEvent(user.id, clientApp, 'bind_phone', {
|
||||||
|
phone: this.maskPhone(phone),
|
||||||
|
method: 'wechat',
|
||||||
|
});
|
||||||
|
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);
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +847,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.userAddressService.normalizeDefaultAddress(primaryId);
|
||||||
return this.assertActiveUser(primaryId);
|
return this.assertActiveUser(primaryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { SmsScene } from '@dukang/shared-types';
|
||||||
|
|
||||||
export class SendSmsDto {
|
export class SendSmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -7,6 +8,7 @@ export class SendSmsDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@IsIn(Object.values(SmsScene))
|
||||||
scene: string;
|
scene: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
PartnerAuthController,
|
PartnerAuthController,
|
||||||
@@ -19,6 +20,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
|
forwardRef(() => AnalyticsModule),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||||
@@ -33,6 +35,6 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
|||||||
AdminAuthController,
|
AdminAuthController,
|
||||||
],
|
],
|
||||||
providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard],
|
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 {}
|
export class IamModule {}
|
||||||
|
|||||||
@@ -14,22 +14,37 @@ export class UserAddressService {
|
|||||||
return serializeBigInt(list);
|
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>) {
|
async create(userId: bigint, body: Record<string, unknown>) {
|
||||||
const isDefault = body.isDefault ? 1 : 0;
|
const isDefault = body.isDefault ? 1 : 0;
|
||||||
if (isDefault) {
|
const address = await this.prisma.$transaction(async (tx) => {
|
||||||
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
if (isDefault) {
|
||||||
}
|
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
||||||
const address = await this.prisma.userAddress.create({
|
}
|
||||||
data: {
|
return tx.userAddress.create({
|
||||||
userId,
|
data: {
|
||||||
receiverName: String(body.receiverName),
|
userId,
|
||||||
phone: String(body.phone),
|
receiverName: String(body.receiverName),
|
||||||
province: String(body.province),
|
phone: String(body.phone),
|
||||||
city: String(body.city),
|
province: String(body.province),
|
||||||
district: String(body.district),
|
city: String(body.city),
|
||||||
detail: String(body.detail),
|
district: String(body.district),
|
||||||
isDefault,
|
detail: String(body.detail),
|
||||||
},
|
isDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return serializeBigInt(address);
|
return serializeBigInt(address);
|
||||||
}
|
}
|
||||||
@@ -37,20 +52,22 @@ export class UserAddressService {
|
|||||||
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
|
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
|
||||||
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
||||||
if (!existing) throw new NotFoundException('地址不存在');
|
if (!existing) throw new NotFoundException('地址不存在');
|
||||||
if (body.isDefault) {
|
const address = await this.prisma.$transaction(async (tx) => {
|
||||||
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
if (body.isDefault) {
|
||||||
}
|
await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
||||||
const address = await this.prisma.userAddress.update({
|
}
|
||||||
where: { id },
|
return tx.userAddress.update({
|
||||||
data: {
|
where: { id },
|
||||||
receiverName: body.receiverName ? String(body.receiverName) : undefined,
|
data: {
|
||||||
phone: body.phone ? String(body.phone) : undefined,
|
receiverName: body.receiverName ? String(body.receiverName) : undefined,
|
||||||
province: body.province ? String(body.province) : undefined,
|
phone: body.phone ? String(body.phone) : undefined,
|
||||||
city: body.city ? String(body.city) : undefined,
|
province: body.province ? String(body.province) : undefined,
|
||||||
district: body.district ? String(body.district) : undefined,
|
city: body.city ? String(body.city) : undefined,
|
||||||
detail: body.detail ? String(body.detail) : undefined,
|
district: body.district ? String(body.district) : undefined,
|
||||||
isDefault: body.isDefault ? 1 : undefined,
|
detail: body.detail ? String(body.detail) : undefined,
|
||||||
},
|
isDefault: body.isDefault ? 1 : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return serializeBigInt(address);
|
return serializeBigInt(address);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
||||||
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||||
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
@@ -27,11 +28,23 @@ export class AdminProductsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.commonProductItem.count({ where }),
|
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({
|
return serializeBigInt({
|
||||||
items: items.map((p) => ({
|
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
|
||||||
...p,
|
|
||||||
mainImageUrl: p.coverResource?.url ?? null,
|
|
||||||
})),
|
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -44,7 +57,18 @@ export class AdminProductsService {
|
|||||||
include: { coverResource: true },
|
include: { coverResource: true },
|
||||||
});
|
});
|
||||||
if (!product) throw new NotFoundException('商品不存在');
|
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) {
|
async create(dto: CreateProductDto) {
|
||||||
@@ -65,26 +89,19 @@ export class AdminProductsService {
|
|||||||
benefitAmount: dto.benefitAmount ?? dto.price,
|
benefitAmount: dto.benefitAmount ?? dto.price,
|
||||||
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
...(dto.detailContent !== undefined
|
||||||
|
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
const cover = await this.prisma.commonResource.create({
|
await this.syncCover(product.id, dto.coverUrl);
|
||||||
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.syncProductMedia(product.id, {
|
||||||
|
carouselUrls: dto.carouselUrls,
|
||||||
|
detailImageUrls: dto.detailImageUrls,
|
||||||
|
});
|
||||||
|
|
||||||
return this.detail(product.id);
|
return this.detail(product.id);
|
||||||
}
|
}
|
||||||
@@ -101,35 +118,96 @@ export class AdminProductsService {
|
|||||||
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
|
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
|
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
|
||||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(dto.detailContent !== undefined
|
||||||
|
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } });
|
await this.syncCover(id, dto.coverUrl);
|
||||||
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.syncProductMedia(id, {
|
||||||
|
carouselUrls: dto.carouselUrls,
|
||||||
|
detailImageUrls: dto.detailImageUrls,
|
||||||
|
});
|
||||||
|
|
||||||
return this.detail(id);
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||||
|
import {
|
||||||
|
XiaofeixiaBatchShipmentQueryDto,
|
||||||
|
XiaofeixiaCheckCoverageDto,
|
||||||
|
XiaofeixiaCreateShipmentDto,
|
||||||
|
XiaofeixiaEstimateFreightDto,
|
||||||
|
XiaofeixiaShipmentQueryDto,
|
||||||
|
} from './dto/admin-courier.dto';
|
||||||
|
|
||||||
|
/** HQ 小飞侠接口联调(仅管理端,勿对 C 端暴露) */
|
||||||
|
@Controller('admin/courier/xiaofeixia')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminXiaofeixiaController {
|
||||||
|
constructor(private readonly service: AdminXiaofeixiaService) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
getConfig() {
|
||||||
|
return this.service.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('estimate-freight')
|
||||||
|
estimateFreight(@Body() body: XiaofeixiaEstimateFreightDto) {
|
||||||
|
return this.service.estimateFreight(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('check-coverage')
|
||||||
|
checkCoverage(@Body() body: XiaofeixiaCheckCoverageDto) {
|
||||||
|
return this.service.checkCoverage(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create-shipment')
|
||||||
|
createShipment(@Body() body: XiaofeixiaCreateShipmentDto) {
|
||||||
|
return this.service.createShipment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('cancel-shipment')
|
||||||
|
cancelShipment(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
|
return this.service.cancelShipment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('get-shipment')
|
||||||
|
getShipment(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
|
return this.service.getShipment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch-get-shipments')
|
||||||
|
batchGetShipments(@Body() body: XiaofeixiaBatchShipmentQueryDto) {
|
||||||
|
return this.service.batchGetShipments(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('get-track')
|
||||||
|
getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
|
return this.service.getTrack(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { CourierConfigService } from '../../integrations/courier/courier.config';
|
||||||
|
import { CourierApiError } from '../../integrations/courier/courier.error';
|
||||||
|
import { CourierService } from '../../integrations/courier/courier.service';
|
||||||
|
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||||
|
import type {
|
||||||
|
BatchShipmentQuery,
|
||||||
|
CreateShipmentInput,
|
||||||
|
ShipmentQuery,
|
||||||
|
} from '../../integrations/courier/courier.types';
|
||||||
|
import type {
|
||||||
|
XiaofeixiaBatchShipmentQueryDto,
|
||||||
|
XiaofeixiaCheckCoverageDto,
|
||||||
|
XiaofeixiaCreateShipmentDto,
|
||||||
|
XiaofeixiaEstimateFreightDto,
|
||||||
|
XiaofeixiaShipmentQueryDto,
|
||||||
|
} from './dto/admin-courier.dto';
|
||||||
|
|
||||||
|
function maskSecret(value: string, visible = 4) {
|
||||||
|
if (!value) return '';
|
||||||
|
if (value.length <= visible) return '*'.repeat(value.length);
|
||||||
|
return `${value.slice(0, visible)}${'*'.repeat(Math.min(8, value.length - visible))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminXiaofeixiaService {
|
||||||
|
constructor(
|
||||||
|
private readonly courier: CourierService,
|
||||||
|
private readonly courierConfig: CourierConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getConfig() {
|
||||||
|
const cfg = this.courierConfig.load();
|
||||||
|
const xfx = cfg.xiaofeixia;
|
||||||
|
return {
|
||||||
|
provider: cfg.provider,
|
||||||
|
activeProvider: this.courier.activeProvider,
|
||||||
|
apiUrl: xfx.apiUrl,
|
||||||
|
appId: xfx.appId ?? null,
|
||||||
|
mchId: xfx.mchId || null,
|
||||||
|
mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null,
|
||||||
|
hasApiKey: Boolean(xfx.apiKey),
|
||||||
|
signType: xfx.signType,
|
||||||
|
ready: Boolean(xfx.mchId && xfx.apiKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async estimateFreight(dto: XiaofeixiaEstimateFreightDto) {
|
||||||
|
return this.wrap(() => this.courier.estimateFreight(dto.weight));
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkCoverage(dto: XiaofeixiaCheckCoverageDto) {
|
||||||
|
return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createShipment(dto: XiaofeixiaCreateShipmentDto) {
|
||||||
|
const input = this.mapCreateInput(dto);
|
||||||
|
return this.wrap(() => this.courier.createShipment(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelShipment(dto: XiaofeixiaShipmentQueryDto) {
|
||||||
|
const query = this.mapShipmentQuery(dto);
|
||||||
|
return this.wrap(async () => {
|
||||||
|
await this.courier.cancelShipment(query);
|
||||||
|
return { cancelled: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShipment(dto: XiaofeixiaShipmentQueryDto) {
|
||||||
|
const query = this.mapShipmentQuery(dto);
|
||||||
|
return this.wrap(() => this.courier.getShipment(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) {
|
||||||
|
const query: BatchShipmentQuery = {
|
||||||
|
trackingNumbers: dto.trackingNumbers?.filter(Boolean),
|
||||||
|
outNumbers: dto.outNumbers?.filter(Boolean),
|
||||||
|
};
|
||||||
|
return this.wrap(() => this.courier.batchGetShipments(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrack(dto: XiaofeixiaShipmentQueryDto) {
|
||||||
|
const query = this.mapShipmentQuery(dto);
|
||||||
|
return this.wrap(() => this.courier.getTrack(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery {
|
||||||
|
if (!dto.trackingNumber && !dto.outNumber) {
|
||||||
|
throw new BadRequestException('运单号与商家单号至少填一个');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
trackingNumber: dto.trackingNumber,
|
||||||
|
outNumber: dto.outNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapCreateInput(dto: XiaofeixiaCreateShipmentDto): CreateShipmentInput {
|
||||||
|
const coord = (lng?: number, lat?: number) =>
|
||||||
|
lng != null && lat != null ? { lng, lat } : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
outNumber: dto.outNumber,
|
||||||
|
customerId: dto.customerId,
|
||||||
|
from: {
|
||||||
|
name: dto.fromName,
|
||||||
|
mobile: dto.fromMobile,
|
||||||
|
address: dto.fromAddress,
|
||||||
|
addressDetail: dto.fromAddressDetail,
|
||||||
|
coordinate: coord(dto.fromLng, dto.fromLat),
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
name: dto.toName,
|
||||||
|
mobile: dto.toMobile,
|
||||||
|
address: dto.toAddress,
|
||||||
|
addressDetail: dto.toAddressDetail,
|
||||||
|
coordinate: coord(dto.toLng, dto.toLat),
|
||||||
|
},
|
||||||
|
goodsName: dto.goodsName,
|
||||||
|
goodsNum: dto.goodsNum,
|
||||||
|
weight: dto.weight,
|
||||||
|
insuredSumPrice: dto.insuredSumPrice,
|
||||||
|
collectionPrice: dto.collectionPrice,
|
||||||
|
payMode: dto.payMode as CourierPayMode,
|
||||||
|
remark: dto.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async wrap<T>(fn: () => Promise<T>) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const data = await fn();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CourierApiError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
error: err.message,
|
||||||
|
code: err.code,
|
||||||
|
provider: err.providerCode,
|
||||||
|
raw: err.raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class XiaofeixiaEstimateFreightDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaofeixiaCheckCoverageDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
toAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaofeixiaShipmentQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trackingNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
outNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaofeixiaBatchShipmentQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
trackingNumbers?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
outNumbers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaofeixiaCreateShipmentDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
outNumber: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
fromName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
fromMobile: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
fromAddress: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
fromAddressDetail: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
fromLng?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
fromLat?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
toName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
toMobile: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
toAddress: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
toAddressDetail: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
toLng?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
toLat?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
goodsName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
goodsNum?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
insuredSumPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
collectionPrice?: number;
|
||||||
|
|
||||||
|
@IsIn(['1', '2'])
|
||||||
|
payMode: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
@@ -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 {
|
export class UpdateStoreStatusDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -391,6 +391,20 @@ export class CreateProductDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
coverUrl?: string;
|
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 {
|
export class UpdateProductDto {
|
||||||
@@ -425,4 +439,18 @@ export class UpdateProductDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
coverUrl?: string;
|
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;
|
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 {
|
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -21,14 +21,19 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
|
|||||||
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
||||||
import { AdminProductsController } from './admin-products.controller';
|
import { AdminProductsController } from './admin-products.controller';
|
||||||
import { AdminProductsService } from './admin-products.service';
|
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 { AdminTicketsController } from './admin-tickets.controller';
|
||||||
import { AdminTicketsService } from './admin-tickets.service';
|
import { AdminTicketsService } from './admin-tickets.service';
|
||||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
|
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
|
||||||
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
|
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminDashboardController,
|
AdminDashboardController,
|
||||||
AdminUsersController,
|
AdminUsersController,
|
||||||
@@ -45,7 +50,9 @@ import { CommonModule } from '../common/common.module';
|
|||||||
AdminDeliveriesController,
|
AdminDeliveriesController,
|
||||||
AdminHqAccountsController,
|
AdminHqAccountsController,
|
||||||
AdminProductsController,
|
AdminProductsController,
|
||||||
|
AdminUserLogsController,
|
||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
|
AdminXiaofeixiaController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
@@ -59,7 +66,9 @@ import { CommonModule } from '../common/common.module';
|
|||||||
AdminDeliveriesService,
|
AdminDeliveriesService,
|
||||||
AdminHqAccountsService,
|
AdminHqAccountsService,
|
||||||
AdminProductsService,
|
AdminProductsService,
|
||||||
|
AdminUserLogsService,
|
||||||
AdminTicketsService,
|
AdminTicketsService,
|
||||||
|
AdminXiaofeixiaService,
|
||||||
SuperAdminGuard,
|
SuperAdminGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
import { SettlementModule } from '../settlement/settlement.module';
|
import { SettlementModule } from '../settlement/settlement.module';
|
||||||
@@ -6,7 +7,7 @@ import { RedeemService } from './redeem.service';
|
|||||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
|
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)],
|
||||||
controllers: [UserRedeemController, ShopRedeemController],
|
controllers: [UserRedeemController, ShopRedeemController],
|
||||||
providers: [RedeemService],
|
providers: [RedeemService],
|
||||||
exports: [RedeemService],
|
exports: [RedeemService],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
|||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { SettlementService } from '../settlement/settlement.service';
|
import { SettlementService } from '../settlement/settlement.service';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export class RedeemService {
|
|||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly settlementService: SettlementService,
|
private readonly settlementService: SettlementService,
|
||||||
private readonly benefitService: BenefitService,
|
private readonly benefitService: BenefitService,
|
||||||
|
private readonly analyticsService: AnalyticsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
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.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
||||||
await this.redis.del(`redeem:token:${body.token}`);
|
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);
|
return serializeBigInt(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
@@ -8,7 +9,7 @@ import { TradeController, PartnerOrderController, PartnerReshipmentController }
|
|||||||
import { TradeService } from './trade.service';
|
import { TradeService } from './trade.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
|
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule],
|
||||||
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
||||||
providers: [TradeService],
|
providers: [TradeService],
|
||||||
exports: [TradeService],
|
exports: [TradeService],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { CatalogService } from '../catalog/catalog.service';
|
import { CatalogService } from '../catalog/catalog.service';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
import { TicketService } from '../common/ticket.service';
|
import { TicketService } from '../common/ticket.service';
|
||||||
@@ -37,6 +38,7 @@ export class TradeService {
|
|||||||
private readonly ipGeoService: IpGeoService,
|
private readonly ipGeoService: IpGeoService,
|
||||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||||
|
private readonly analyticsService: AnalyticsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||||
@@ -158,6 +160,17 @@ export class TradeService {
|
|||||||
include: { product: true, imageResource: true },
|
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));
|
return serializeBigInt(mapOrderCompat(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +239,13 @@ export class TradeService {
|
|||||||
await this.benefitService.grantOnOrderPaid(order.id);
|
await this.benefitService.grantOnOrderPaid(order.id);
|
||||||
await this.deliveryProvider.scheduleAutoAdvance(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);
|
return this.getOrder(userId, orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +325,12 @@ export class TradeService {
|
|||||||
if (refreshed?.payStatus === 'PAID') {
|
if (refreshed?.payStatus === 'PAID') {
|
||||||
await this.benefitService.grantOnOrderPaid(order.id);
|
await this.benefitService.grantOnOrderPaid(order.id);
|
||||||
await this.deliveryProvider.scheduleAutoAdvance(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 };
|
return { orderId: order.id.toString(), alreadyPaid: false };
|
||||||
|
|||||||
Reference in New Issue
Block a user