From 1c978b8adc4ed746e66e16093cba78bc4fb91cca Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 13:18:56 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9F=AD=E4=BF=A1=E9=AA=8C=E8=AF=81=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 4 +- apps/admin-web/src/layouts/AdminLayout.tsx | 13 +- apps/admin-web/src/lib/user-log.ts | 45 +++ apps/admin-web/src/pages/ProductsPage.tsx | 257 +++++++++++--- apps/admin-web/src/pages/UserLogsPage.tsx | 156 +++++++++ apps/admin-web/src/pages/UsersPage.tsx | 22 +- apps/h5-user/src/App.tsx | 2 + .../src/components/ContactCustomerSheet.tsx | 70 ++++ .../src/components/PhoneVerifySheet.tsx | 72 ++-- .../src/contexts/UserSessionContext.tsx | 6 +- apps/h5-user/src/lib/analytics.ts | 16 + apps/h5-user/src/lib/use-sms-code.ts | 81 +++++ apps/h5-user/src/pages/AddressListPage.tsx | 6 +- .../h5-user/src/pages/CustomerServicePage.tsx | 186 ++++++++++ apps/h5-user/src/pages/HomePage.tsx | 5 + apps/h5-user/src/pages/LoginPage.tsx | 74 ++-- apps/h5-user/src/pages/MinePage.tsx | 6 +- apps/h5-user/src/pages/OrderConfirmPage.tsx | 15 +- apps/h5-user/src/pages/OrderDetailPage.tsx | 34 +- apps/h5-user/src/pages/OrderListPage.tsx | 12 +- apps/h5-user/src/pages/ProductDetailPage.tsx | 62 ++-- apps/h5-user/src/pages/StoreDetailPage.tsx | 6 +- apps/h5-user/src/pages/StoreListPage.tsx | 5 + apps/h5-user/src/styles.css | 251 ++++++++++++++ apps/h5-user/vite.config.ts | 1 + packages/shared-types/package.json | 12 + packages/shared-types/src/catalog.ts | 15 + packages/shared-types/src/config.ts | 11 + packages/shared-types/src/index.ts | 1 + packages/shared-types/src/user-log.ts | 61 ++++ packages/shared-types/src/wechat.ts | 1 + pnpm-lock.yaml | 328 ++++++++++++++++-- pnpm-workspace.yaml | 1 + scripts/smoke-prev1.mjs | 67 ++-- scripts/smoke-v3.mjs | 42 ++- server/dukang-api/.env.example | 13 +- server/dukang-api/.env.production.example | 4 + server/dukang-api/package.json | 3 + server/dukang-api/src/app.module.ts | 2 +- .../src/integrations/integrations.module.ts | 22 +- .../src/integrations/sms/sms-code.store.ts | 51 +++ .../integrations/sms/sms.aliyun.provider.ts | 109 ++++++ .../src/integrations/sms/sms.mock.provider.ts | 37 +- server/dukang-api/src/load-env.ts | 29 ++ server/dukang-api/src/main.ts | 5 + .../src/modules/analytics/analytics.module.ts | 5 +- .../modules/analytics/analytics.service.ts | 39 ++- .../common/client-config.controller.ts | 1 + .../src/modules/iam/auth.service.ts | 93 ++++- .../src/modules/iam/dto/auth.dto.ts | 2 + .../dukang-api/src/modules/iam/iam.module.ts | 6 +- .../src/modules/iam/user-address.service.ts | 73 ++-- .../src/modules/ops/admin-products.service.ts | 164 ++++++--- .../modules/ops/admin-user-logs.controller.ts | 20 ++ .../modules/ops/admin-user-logs.service.ts | 112 ++++++ .../src/modules/ops/dto/admin-mutate.dto.ts | 30 +- .../src/modules/ops/dto/admin-query.dto.ts | 30 ++ .../dukang-api/src/modules/ops/ops.module.ts | 4 + .../src/modules/redeem/redeem.module.ts | 3 +- .../src/modules/redeem/redeem.service.ts | 13 + .../src/modules/trade/trade.module.ts | 3 +- .../src/modules/trade/trade.service.ts | 26 ++ 62 files changed, 2491 insertions(+), 354 deletions(-) create mode 100644 apps/admin-web/src/lib/user-log.ts create mode 100644 apps/admin-web/src/pages/UserLogsPage.tsx create mode 100644 apps/h5-user/src/components/ContactCustomerSheet.tsx create mode 100644 apps/h5-user/src/lib/analytics.ts create mode 100644 apps/h5-user/src/lib/use-sms-code.ts create mode 100644 apps/h5-user/src/pages/CustomerServicePage.tsx create mode 100644 packages/shared-types/src/user-log.ts create mode 100644 server/dukang-api/src/integrations/sms/sms-code.store.ts create mode 100644 server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts create mode 100644 server/dukang-api/src/load-env.ts create mode 100644 server/dukang-api/src/modules/ops/admin-user-logs.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-user-logs.service.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 268d91a..040e2aa 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -21,6 +21,7 @@ import ResourcesPage from './pages/ResourcesPage'; import StorePayoutsPage from './pages/StorePayoutsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import TicketsPage from './pages/TicketsPage'; +import UserLogsPage from './pages/UserLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { @@ -56,7 +57,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index e6f5e98..c2ae20c 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -13,6 +13,7 @@ import { SafetyOutlined, LogoutOutlined, CloudUploadOutlined, + FileTextOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; @@ -57,7 +58,15 @@ const MENU_ITEMS: MenuProps['items'] = [ }, { key: '/partner-bills', icon: , label: '合伙人结算' }, { key: '/tickets', icon: , label: '工单中心' }, - { key: '/third-party-logs', icon: , label: '第三方日志' }, + { + key: 'logs-group', + icon: , + label: '日志', + children: [ + { key: '/logs/users', label: '用户日志' }, + { key: '/logs/third-party', label: '第三方日志' }, + ], + }, { key: '/deliveries', icon: , label: '配送单' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; @@ -86,7 +95,7 @@ export default function AdminLayout() { theme="dark" mode="inline" selectedKeys={[selectedKey]} - defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group']} + defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group']} items={MENU_ITEMS} onClick={({ key }) => { if (key.startsWith('/')) navigate(key); diff --git a/apps/admin-web/src/lib/user-log.ts b/apps/admin-web/src/lib/user-log.ts new file mode 100644 index 0000000..4c3a87a --- /dev/null +++ b/apps/admin-web/src/lib/user-log.ts @@ -0,0 +1,45 @@ +export type UserLogCategory = + | 'login' + | 'browse_product' + | 'order' + | 'pay' + | 'wechat_auth' + | 'browse_store' + | 'redeem' + | 'profile'; + +const USER_LOG_EVENT_CATEGORIES: Record = { + login: ['login_success', 'sms_login'], + browse_product: ['home_view', 'product_click', 'product_detail_view'], + order: ['order_confirm_view', 'order_submit'], + pay: ['pay_success', 'pay_fail'], + wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'], + browse_store: ['store_list_view', 'store_detail_view'], + redeem: ['benefit_redeem_start', 'benefit_redeem_success'], + profile: ['profile_update'], +}; + +export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [ + { value: '', label: '全部' }, + { value: 'login', label: '登录' }, + { value: 'browse_product', label: '浏览商品' }, + { value: 'order', label: '下单' }, + { value: 'pay', label: '支付' }, + { value: 'wechat_auth', label: '微信授权' }, + { value: 'browse_store', label: '浏览门店' }, + { value: 'redeem', label: '核销' }, + { value: 'profile', label: '信息修改' }, +]; + +export function resolveUserLogCategory(eventName: string): UserLogCategory | null { + for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array< + [UserLogCategory, readonly string[]] + >) { + if (events.includes(eventName)) return category; + } + return null; +} + +export const USER_LOG_CATEGORY_LABELS = Object.fromEntries( + USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]), +) as Record; diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index 117c7ea..fdedc3b 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -1,13 +1,21 @@ import { useState } from 'react'; import { - Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message, + Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space, + Table, Tabs, Tag, Typography, message, } from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; -import { request, type Paginated } from '../lib/api'; +import { request } from '../lib/api'; import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +type ProductDetailContentDto = { + storyTitle?: string; + storyText?: string; + features?: Array<{ icon: string; title: string; desc: string }>; +}; + type Row = { id: string; skuCode: string; @@ -21,9 +29,196 @@ type Row = { status: string; sortOrder: number; mainImageUrl?: string | null; + carouselUrls?: string[]; + detailImageUrls?: string[]; + detailContent?: ProductDetailContentDto | null; createdAt: string; }; +type ProductFormValues = { + skuCode?: string; + barcode69?: string; + name: string; + subtitle?: string; + aromaType?: string; + spec: string; + price: number; + benefitAmount?: number; + status?: string; + sortOrder?: number; + coverUrl?: string; + carouselUrls?: string[]; + detailImageUrls?: string[]; + storyTitle?: string; + storyText?: string; + features?: Array<{ icon?: string; title?: string; desc?: string }>; +}; + +function mapDetailToForm(d: Record) { + 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 ( + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + {fields.length > 1 && ( + remove(field.name)} style={{ marginTop: 8 }} /> + )} + + ))} + + + )} + + ); +} + +function ProductDetailFields() { + return ( + <> + 详情页轮播(CAROUSEL) + + + 详情长图(DETAIL) + + + + + + + + + 卖点特色 + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + + + + + {fields.length > 1 && ( + remove(field.name)} style={{ marginTop: 30 }} /> + )} + + + + + + ))} + + + )} + + + ); +} + +function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) { + return ( + <> + {mode === 'create' && ( + <> + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + { setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + {String(detail.id)} + {String(detail.nickname || detail.userNo || detail.userId || '—')} + {String(detail.phone || '—')} + {CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')} + {String(detail.eventName)} + {String(detail.clientApp || '—')} + {detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'} + {fmtTime(String(detail.createdAt))} + +
+                {JSON.stringify(detail.extraJson ?? {}, null, 2)}
+              
+
+
+ )} +
+ + ); +} diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 95a3bf2..695e172 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Descriptions, @@ -24,6 +25,7 @@ type UserDetail = AdminUserRow & { }; export default function UsersPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); @@ -99,11 +101,16 @@ export default function UsersPage() { }, { title: '操作', - width: 80, + width: 140, render: (_, row) => ( - + + + + ), }, ]; @@ -198,6 +205,13 @@ export default function UsersPage() { /> )} + )} diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx index 65af6ba..78703af 100644 --- a/apps/h5-user/src/App.tsx +++ b/apps/h5-user/src/App.tsx @@ -17,6 +17,7 @@ import RedeemPage from './pages/RedeemPage'; import RedeemCodePage from './pages/RedeemCodePage'; import RedeemSuccessPage from './pages/RedeemSuccessPage'; import PayPage from './pages/PayPage'; +import CustomerServicePage from './pages/CustomerServicePage'; import { UserSessionProvider } from './contexts/UserSessionContext'; export default function App() { @@ -34,6 +35,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/h5-user/src/components/ContactCustomerSheet.tsx b/apps/h5-user/src/components/ContactCustomerSheet.tsx new file mode 100644 index 0000000..212f2bf --- /dev/null +++ b/apps/h5-user/src/components/ContactCustomerSheet.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+

联系客服

+ +
+ +
+ + + +
+ + +
+
+ ); +} diff --git a/apps/h5-user/src/components/PhoneVerifySheet.tsx b/apps/h5-user/src/components/PhoneVerifySheet.tsx index 1cb2032..17e236b 100644 --- a/apps/h5-user/src/components/PhoneVerifySheet.tsx +++ b/apps/h5-user/src/components/PhoneVerifySheet.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react'; -import { bindPhone, request, saveSession } from '../lib/api'; +import { SmsScene } from '@dukang/shared-types'; +import { bindPhone, type SessionPayload } from '../lib/api'; import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; +import { useSmsCode } from '../lib/use-sms-code'; +import { useUserSession } from '../contexts/UserSessionContext'; type PhoneVerifySheetProps = { open: boolean; @@ -16,18 +19,18 @@ export default function PhoneVerifySheet({ onClose, onSuccess, }: PhoneVerifySheetProps) { + const { applySession } = useUserSession(); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); - const [msg, setMsg] = useState(''); const [loading, setLoading] = useState(false); - const [codeCooldown, setCodeCooldown] = useState(0); + const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode(); useEffect(() => { if (!open) { setPhone(''); setCode(''); - setMsg(''); - setCodeCooldown(0); + setError(''); + clearMessages(); return; } if (defaultPhone) { @@ -36,51 +39,32 @@ export default function PhoneVerifySheet({ setPhone(normalized); } } - }, [open, defaultPhone]); + }, [open, defaultPhone, clearMessages, setError]); - async function sendCode() { - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setMsg(phoneCheck.message ?? '请输入正确的手机号码'); - return; - } - setMsg(''); - await request('USER_H5', '/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone, scene: 'BIND_PHONE' }), - }); - setMsg('验证码已发送(Mock: 123456)'); - setCodeCooldown(60); - const timer = setInterval(() => { - setCodeCooldown((c) => { - if (c <= 1) { - clearInterval(timer); - return 0; - } - return c - 1; - }); - }, 1000); + async function onSendCode() { + clearMessages(); + await sendCode(phone, SmsScene.BIND_PHONE); } async function submit() { const phoneCheck = validateMobilePhone(phone); if (!phoneCheck.ok) { - setMsg(phoneCheck.message ?? '请输入正确的手机号码'); + setError(phoneCheck.message ?? '请输入正确的手机号码'); return; } if (!code.trim()) { - setMsg('请输入验证码'); + setError('请输入验证码'); return; } setLoading(true); - setMsg(''); + setError(''); try { const session = await bindPhone(phone, code); - saveSession(session); + applySession(session as SessionPayload); onSuccess(); onClose(); } catch (e) { - setMsg(e instanceof Error ? e.message : '验证失败'); + setError(e instanceof Error ? e.message : '验证失败'); } finally { setLoading(false); } @@ -105,7 +89,8 @@ export default function PhoneVerifySheet({ value={phone} onChange={(e) => { setPhone(normalizePhoneInput(e.target.value)); - setMsg(''); + setError(''); + clearMessages(); }} /> @@ -115,19 +100,26 @@ export default function PhoneVerifySheet({ inputMode="numeric" className="login-field-input" placeholder="请输入验证码" + maxLength={6} value={code} - onChange={(e) => setCode(e.target.value)} + onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} /> - {msg &&

{msg}

} + {(error || sentHint) && ( +

{error || sentHint}

+ )} diff --git a/apps/h5-user/src/contexts/UserSessionContext.tsx b/apps/h5-user/src/contexts/UserSessionContext.tsx index ca5a156..92955a1 100644 --- a/apps/h5-user/src/contexts/UserSessionContext.tsx +++ b/apps/h5-user/src/contexts/UserSessionContext.tsx @@ -13,6 +13,7 @@ import { ensureSession, getDeviceKey, request, + saveSession, type SessionPayload, type UserProfile, } from '../lib/api'; @@ -21,6 +22,7 @@ type UserSessionContextValue = { ready: boolean; profile: UserProfile | null; phoneVerified: boolean; + applySession: (session: SessionPayload) => void; refreshProfile: () => Promise; resetSession: () => Promise; }; @@ -33,6 +35,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) { const [phoneVerified, setPhoneVerified] = useState(false); const applySession = useCallback((session: SessionPayload) => { + saveSession(session); if (session.user) setProfile(session.user); setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified); }, []); @@ -83,10 +86,11 @@ export function UserSessionProvider({ children }: { children: ReactNode }) { ready, profile, phoneVerified, + applySession, refreshProfile, resetSession, }), - [ready, profile, phoneVerified, refreshProfile, resetSession], + [ready, profile, phoneVerified, applySession, refreshProfile, resetSession], ); if (!ready) { diff --git a/apps/h5-user/src/lib/analytics.ts b/apps/h5-user/src/lib/analytics.ts new file mode 100644 index 0000000..81adcaf --- /dev/null +++ b/apps/h5-user/src/lib/analytics.ts @@ -0,0 +1,16 @@ +import { apiBase } from './api'; + +export function track(eventName: string, params?: Record) { + 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(() => {}); +} diff --git a/apps/h5-user/src/lib/use-sms-code.ts b/apps/h5-user/src/lib/use-sms-code.ts new file mode 100644 index 0000000..d9c8600 --- /dev/null +++ b/apps/h5-user/src/lib/use-sms-code.ts @@ -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 | 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, + }; +} diff --git a/apps/h5-user/src/pages/AddressListPage.tsx b/apps/h5-user/src/pages/AddressListPage.tsx index 177c1fd..7f17d39 100644 --- a/apps/h5-user/src/pages/AddressListPage.tsx +++ b/apps/h5-user/src/pages/AddressListPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import SubPageHeader from '../components/SubPageHeader'; import { request } from '../lib/api'; +import { useUserSession } from '../contexts/UserSessionContext'; import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation'; type Address = { @@ -24,6 +25,7 @@ function formatAddress(a: Address) { } export default function AddressListPage() { + const { profile } = useUserSession(); const [list, setList] = useState([]); const [pendingAddress, setPendingAddress] = useState
(null); const [savingOrderAddress, setSavingOrderAddress] = useState(false); @@ -42,7 +44,7 @@ export default function AddressListPage() { useEffect(() => { loadList(); - }, [loadList]); + }, [loadList, profile?.id]); function selectAddress(addr: Address) { if (!selectMode) return; @@ -137,7 +139,7 @@ export default function AddressListPage() {
{list.map((a) => { - const isDefault = a.isDefault === 1; + const isDefault = Number(a.isDefault) === 1; const isSelected = selectMode && currentAddressId === String(a.id); return (
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([]); + const [sending, setSending] = useState(false); + const listRef = useRef(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>('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 ( +
+ navigate(-1)} /> + + {orderNo && ( +
+ receipt_long +
+

当前咨询订单

+

{orderNo}

+
+
+ )} + +
+ {messages.map((m) => { + if (m.role === 'system') { + return ( +
+ {m.text} +
+ ); + } + const isUser = m.role === 'user'; + return ( +
+ {!isUser && ( +
+ support_agent +
+ )} +
{m.text}
+
+ ); + })} +
+ +
+ {QUICK_QUESTIONS.map((q) => ( + + ))} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void sendText(input); + }} + /> + +
+
+ ); +} diff --git a/apps/h5-user/src/pages/HomePage.tsx b/apps/h5-user/src/pages/HomePage.tsx index 945d764..c7db908 100644 --- a/apps/h5-user/src/pages/HomePage.tsx +++ b/apps/h5-user/src/pages/HomePage.tsx @@ -5,6 +5,7 @@ import ProductCarousel from '../components/ProductCarousel'; import TabMainHeader from '../components/TabMainHeader'; import AppToast from '../components/AppToast'; import { getProductImages } from '../lib/product-images'; +import { track } from '../lib/analytics'; import CouponBadge from '@dukang/shared-ui/CouponBadge'; type Product = { @@ -42,6 +43,10 @@ export default function HomePage() { const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100'); const [toast, setToast] = useState(''); + useEffect(() => { + track('home_view', { pagePath: '/' }); + }, []); + useEffect(() => { request('USER_H5', '/catalog/cities').then((list) => { setCities(list); diff --git a/apps/h5-user/src/pages/LoginPage.tsx b/apps/h5-user/src/pages/LoginPage.tsx index 12441f4..02da6ab 100644 --- a/apps/h5-user/src/pages/LoginPage.tsx +++ b/apps/h5-user/src/pages/LoginPage.tsx @@ -2,22 +2,27 @@ import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import type { WechatLoginResult } from '@dukang/shared-types'; -import { request, saveSession, type UserProfile } from '../lib/api'; +import { SmsScene } from '@dukang/shared-types'; +import { request, type SessionPayload } from '../lib/api'; import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; +import { useSmsCode } from '../lib/use-sms-code'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; +import { useUserSession } from '../contexts/UserSessionContext'; export default function LoginPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const returnTo = searchParams.get('return') || '/'; - const [phone, setPhone] = useState('13800000001'); - const [code, setCode] = useState('123456'); + const { applySession } = useUserSession(); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); const [loading, setLoading] = useState(false); const [agreed, setAgreed] = useState(true); - const [codeCooldown, setCodeCooldown] = useState(0); const [msg, setMsg] = useState(''); const [wxSessionKey, setWxSessionKey] = useState(null); const [bindMode, setBindMode] = useState(false); + const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } = + useSmsCode(); useEffect(() => { if (!isWechatEnv()) return; @@ -38,12 +43,12 @@ export default function LoginPage() { return; } if (result.accessToken) { - saveSession({ + applySession({ accessToken: result.accessToken, refreshToken: result.refreshToken ?? '', deviceKey: result.deviceKey, phoneVerified: !!result.phoneVerified, - user: result.user as never, + user: result.user as SessionPayload['user'], }); navigate(returnTo.startsWith('/') ? returnTo : '/'); } @@ -57,29 +62,11 @@ export default function LoginPage() { return true; } - async function sendCode() { + async function onSendCode() { if (!ensureAgreed()) return; - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setMsg(phoneCheck.message ?? '请输入正确的手机号码'); - return; - } + clearMessages(); setMsg(''); - await request('USER_H5', '/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }), - }); - setMsg('验证码已发送(Mock: 123456)'); - setCodeCooldown(60); - const timer = setInterval(() => { - setCodeCooldown((c) => { - if (c <= 1) { - clearInterval(timer); - return 0; - } - return c - 1; - }); - }, 1000); + await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN); } async function login() { @@ -95,6 +82,7 @@ export default function LoginPage() { } setLoading(true); setMsg(''); + setSmsError(''); try { if (bindMode && wxSessionKey) { const data = await request('USER_H5', '/auth/wechat/bind-phone', { @@ -104,15 +92,11 @@ export default function LoginPage() { handleWechatLoginResult(data); return; } - const data = await request<{ - accessToken: string; - refreshToken: string; - deviceKey?: string; - }>('USER_H5', '/auth/login/sms', { + const data = await request('USER_H5', '/auth/login/sms', { method: 'POST', body: JSON.stringify({ phone, code }), }); - saveSession(data); + applySession(data); navigate(returnTo.startsWith('/') ? returnTo : '/'); } catch (e) { setMsg(e instanceof Error ? e.message : '登录失败'); @@ -136,6 +120,8 @@ export default function LoginPage() { } } + const displayMsg = msg || smsError; + return (
@@ -164,6 +150,7 @@ export default function LoginPage() { onChange={(e) => { setPhone(normalizePhoneInput(e.target.value)); setMsg(''); + clearMessages(); }} />
@@ -173,19 +160,28 @@ export default function LoginPage() { inputMode="numeric" className="login-field-input" placeholder="请输入验证码" + maxLength={6} value={code} - onChange={(e) => setCode(e.target.value)} + onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} />
- {msg &&

{msg}

} + {(displayMsg || sentHint) && ( +

+ {displayMsg || sentHint} +

+ )} )} + {canPay && ( + + )} {canConfirmReceive && ( - - + setShowCs(false)} + /> )} ); diff --git a/apps/h5-user/src/pages/OrderListPage.tsx b/apps/h5-user/src/pages/OrderListPage.tsx index d5259b4..3abb72f 100644 --- a/apps/h5-user/src/pages/OrderListPage.tsx +++ b/apps/h5-user/src/pages/OrderListPage.tsx @@ -42,6 +42,7 @@ export default function OrderListPage() { const items = (o.items as Array>) || []; const item = items[0]; const isReshipDemo = i === 0 && tab === 'all'; + const isPendingPay = String(o.status) === 'PENDING_PAY'; return (
@@ -63,7 +64,16 @@ export default function OrderListPage() {
¥{Number(o.payAmount)}
)} -
+
+ {isPendingPay && ( + + )} ('USER_H5', `/catalog/products/${id}`).then(setProduct); }, [id]); + useEffect(() => { + if (id) { + track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id }); + } + }, [id]); + useEffect(() => { function onScroll() { setHeaderSolid(window.scrollY > 100); @@ -41,6 +45,8 @@ export default function ProductDetailPage() { const benefit = Number(product.benefitAmount ?? product.price); const carouselImages = getProductCarouselImages(product); const detailImages = getProductDetailImages(product); + const detail = product.detailContent ?? {}; + const features = detail.features ?? []; return (
@@ -107,31 +113,31 @@ export default function ProductDetailPage() {

商品详情

- {detailImages[0] && ( - - )} + {detailImages.map((src, index) => ( + + ))} -
-
-

千年杜康 · 唯有此处

-

- 选自白水杜康核心产区,取山泉之灵气,集五谷之精华。古法酿造工艺,历经九九八十一道工序,方得这一口醇厚绵甜。 -

-
- -
- {FEATURES.map((f) => ( -
- {f.icon} -
{f.title}
-
{f.desc}
+ {(detail.storyTitle || detail.storyText || features.length > 0) && ( +
+ {(detail.storyTitle || detail.storyText) && ( +
+ {detail.storyTitle &&

{detail.storyTitle}

} + {detail.storyText &&

{detail.storyText}

}
- ))} -
-
+ )} - {detailImages.length > 1 && ( - + {features.length > 0 && ( +
+ {features.map((f) => ( +
+ {f.icon} +
{f.title}
+
{f.desc}
+
+ ))} +
+ )} +
)} diff --git a/apps/h5-user/src/pages/StoreDetailPage.tsx b/apps/h5-user/src/pages/StoreDetailPage.tsx index a8b0ee6..ac7b9ad 100644 --- a/apps/h5-user/src/pages/StoreDetailPage.tsx +++ b/apps/h5-user/src/pages/StoreDetailPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import ProductCarousel from '../components/ProductCarousel'; import { request } from '../lib/api'; +import { track } from '../lib/analytics'; import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images'; type StoreMedia = { url: string; mediaType?: string; sortOrder?: number }; @@ -55,7 +56,10 @@ export default function StoreDetailPage() { const [headerSolid, setHeaderSolid] = useState(false); useEffect(() => { - if (id) request('USER_H5', `/stores/${id}`).then(setStore); + if (id) { + request('USER_H5', `/stores/${id}`).then(setStore); + track('store_detail_view', { refType: 'STORE', refId: id, storeId: id }); + } }, [id]); useEffect(() => { diff --git a/apps/h5-user/src/pages/StoreListPage.tsx b/apps/h5-user/src/pages/StoreListPage.tsx index 8e39532..0bad6bd 100644 --- a/apps/h5-user/src/pages/StoreListPage.tsx +++ b/apps/h5-user/src/pages/StoreListPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import { request } from '../lib/api'; +import { track } from '../lib/analytics'; import TabMainHeader from '../components/TabMainHeader'; import RegionPicker from '../components/RegionPicker'; import { @@ -64,6 +65,10 @@ export default function StoreListPage() { const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]); + useEffect(() => { + track('store_list_view', { pagePath: '/stores' }); + }, []); + useEffect(() => { request('USER_H5', '/catalog/cities').then(setCities); }, []); diff --git a/apps/h5-user/src/styles.css b/apps/h5-user/src/styles.css index daaeb2e..3d339d0 100644 --- a/apps/h5-user/src/styles.css +++ b/apps/h5-user/src/styles.css @@ -5541,3 +5541,254 @@ line-height: 1.5; } +.login-msg--hint { + color: var(--color-success, #2d6a4f); +} + +/* Contact customer sheet */ +.contact-customer-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.contact-customer-sheet { + background: #faf9f7; + border-radius: 16px 16px 0 0; + padding: 20px 20px calc(16px + env(safe-area-inset-bottom)); + max-width: 480px; + width: 100%; + margin: 0 auto; +} + +.contact-customer-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.contact-customer-head h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1a1c1b; +} + +.contact-customer-close { + border: none; + background: transparent; + padding: 4px; + color: #5a413f; +} + +.contact-customer-options { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.contact-customer-option { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 16px; + border: 1px solid rgba(226, 190, 188, 0.3); + border-radius: 12px; + background: #f4f3f1; + text-align: left; +} + +.contact-customer-option-icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(166, 29, 36, 0.1); + color: #a61d24; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.contact-customer-option-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #1a1c1b; +} + +.contact-customer-option-sub { + margin: 4px 0 0; + font-size: 12px; + color: #5a413f; +} + +.contact-customer-chevron { + margin-left: auto; + color: #5a413f; +} + +.contact-customer-cancel { + width: 100%; + padding: 16px; + border: none; + border-radius: 12px; + background: #e3e2e0; + font-size: 16px; + font-weight: 600; + color: #1a1c1b; +} + +/* Customer service chat page */ +.customer-service-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: #faf9f7; +} + +.customer-service-order-card { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 16px 0; + padding: 12px 16px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05); + color: #a61d24; +} + +.customer-service-order-label { + margin: 0; + font-size: 12px; + color: #5a413f; +} + +.customer-service-order-no { + margin: 4px 0 0; + font-size: 14px; + font-weight: 600; + color: #1a1c1b; +} + +.customer-service-chat { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.customer-service-time { + display: flex; + justify-content: center; +} + +.customer-service-time span { + font-size: 12px; + color: #999; + background: rgba(227, 226, 224, 0.3); + padding: 4px 12px; + border-radius: 999px; +} + +.customer-service-bubble-row { + display: flex; + gap: 8px; + max-width: 85%; +} + +.customer-service-bubble-row.is-user { + align-self: flex-end; +} + +.customer-service-bubble-row.is-agent { + align-self: flex-start; +} + +.customer-service-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: #fff; + border: 1px solid rgba(226, 190, 188, 0.2); + display: flex; + align-items: center; + justify-content: center; + color: #a61d24; + flex-shrink: 0; +} + +.customer-service-bubble { + padding: 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + background: #fff; + color: #1a1c1b; + box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05); +} + +.customer-service-bubble.is-user { + background: #a61d24; + color: #fff; + box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2); +} + +.customer-service-quick { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px 12px; +} + +.customer-service-quick-btn { + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(166, 29, 36, 0.25); + background: #fff; + color: #a61d24; + font-size: 12px; +} + +.customer-service-inputbar { + display: flex; + gap: 8px; + padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); + background: #fff; + border-top: 1px solid #e3e2e0; +} + +.customer-service-input { + flex: 1; + border: 1px solid #e3e2e0; + border-radius: 999px; + padding: 10px 16px; + font-size: 14px; + outline: none; +} + +.customer-service-send { + border: none; + border-radius: 999px; + padding: 10px 18px; + background: #a61d24; + color: #fff; + font-size: 14px; + font-weight: 600; +} + +.customer-service-send:disabled { + opacity: 0.5; +} + diff --git a/apps/h5-user/vite.config.ts b/apps/h5-user/vite.config.ts index 441197b..ff8d8a9 100644 --- a/apps/h5-user/vite.config.ts +++ b/apps/h5-user/vite.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ resolve: { alias: { '@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'), + '@dukang/shared-types': path.resolve(__dirname, '../../packages/shared-types/src'), }, }, server: { diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index dfe0521..e1092d5 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -4,6 +4,18 @@ "private": true, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./src/index.ts", + "default": "./dist/index.js" + }, + "./user-log": { + "types": "./dist/user-log.d.ts", + "import": "./src/user-log.ts", + "default": "./dist/user-log.js" + } + }, "scripts": { "build": "tsc", "dev": "tsc --watch", diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index 2eefd4c..9307ed5 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -22,6 +22,21 @@ export interface ProductDto { carouselUrls?: string[]; /** 详情长图(bizType=DETAIL 或 detailContent JSON) */ detailImageUrls?: string[]; + detailContent?: ProductDetailContentDto | null; +} + +export interface ProductDetailFeatureDto { + icon: string; + title: string; + desc: string; +} + +export interface ProductDetailContentDto { + storyTitle?: string; + storyText?: string; + features?: ProductDetailFeatureDto[]; + /** 兜底详情图,优先 DETAIL 资源 */ + images?: string[]; } export interface ProductListQuery { diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index d96c5f8..d490016 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -10,8 +10,15 @@ export interface AppConfig { wxMchId: string; /** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */ ossEnabled: boolean; + aliyunSmsSignName: string; + aliyunSmsTemplateCode: string; + aliyunSmsAccessKeyId: string; + aliyunSmsAccessKeySecret: string; } +/** 总部客服电话(C 端联系客服) */ +export const CUSTOMER_SERVICE_PHONE = '400-888-1234'; + export function loadAppConfig(env?: Record): AppConfig { const e = env ?? @@ -28,5 +35,9 @@ export function loadAppConfig(env?: Record): AppConf wxAppId: e.WX_APP_ID ?? '', wxMchId: e.WX_MCH_ID ?? '', ossEnabled: e.OSS_ENABLED === 'true', + aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '', + aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '', + aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '', + aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '', }; } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d881560..723aa6f 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -9,3 +9,4 @@ export * from './redeem'; export * from './settlement'; export * from './ops'; export * from './ticket'; +export * from './user-log'; diff --git a/packages/shared-types/src/user-log.ts b/packages/shared-types/src/user-log.ts new file mode 100644 index 0000000..229119e --- /dev/null +++ b/packages/shared-types/src/user-log.ts @@ -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 = { + login: ['login_success', 'sms_login'], + browse_product: ['home_view', 'product_click', 'product_detail_view'], + order: ['order_confirm_view', 'order_submit'], + pay: ['pay_success', 'pay_fail'], + wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'], + browse_store: ['store_list_view', 'store_detail_view'], + redeem: ['benefit_redeem_start', 'benefit_redeem_success'], + profile: ['profile_update'], +}; + +export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [ + { value: '', label: '全部' }, + { value: 'login', label: '登录' }, + { value: 'browse_product', label: '浏览商品' }, + { value: 'order', label: '下单' }, + { value: 'pay', label: '支付' }, + { value: 'wechat_auth', label: '微信授权' }, + { value: 'browse_store', label: '浏览门店' }, + { value: 'redeem', label: '核销' }, + { value: 'profile', label: '信息修改' }, +]; + +export function resolveUserLogCategory(eventName: string): UserLogCategory | null { + for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array< + [UserLogCategory, readonly string[]] + >) { + if (events.includes(eventName)) return category; + } + return null; +} + +export function eventNamesForUserLogCategory(category: string): string[] | undefined { + if (!category) return undefined; + return [...(USER_LOG_EVENT_CATEGORIES[category as UserLogCategory] ?? [])]; +} + +export interface UserLogRowDto { + id: string; + userId: string | null; + userNo: string | null; + phone: string | null; + nickname: string | null; + category: UserLogCategory | null; + eventName: string; + clientApp: string | null; + refType: string | null; + refId: string | null; + extraJson: Record | null; + createdAt: string; +} diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index c020aa5..8cc1d5d 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -27,6 +27,7 @@ export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED'; export type ClientRuntimeConfig = { mockPay: boolean; wechatPayEnabled: boolean; + mockSms: boolean; }; export interface WechatLoginResult { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8e22ce..48ba073 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: link:../../packages/shared-types antd: specifier: ^5.22.0 - version: 5.29.3(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) dayjs: specifier: ^1.11.13 version: 1.11.21 @@ -56,13 +56,13 @@ importers: version: 18.3.7(@types/react@18.3.31) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0)) + version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0)) typescript: specifier: ^5.4.5 version: 5.9.3 vite: specifier: ^5.4.0 - version: 5.4.21(@types/node@20.19.43)(terser@5.48.0) + version: 5.4.21(@types/node@22.20.0)(terser@5.48.0) apps/h5-partner: dependencies: @@ -96,13 +96,13 @@ importers: version: 18.3.7(@types/react@18.3.31) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0)) + version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0)) typescript: specifier: ^5.4.5 version: 5.9.3 vite: specifier: ^5.4.0 - version: 5.4.21(@types/node@20.19.43)(terser@5.48.0) + version: 5.4.21(@types/node@22.20.0)(terser@5.48.0) apps/h5-shop: dependencies: @@ -133,13 +133,13 @@ importers: version: 18.3.7(@types/react@18.3.31) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0)) + version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0)) typescript: specifier: ^5.4.5 version: 5.9.3 vite: specifier: ^5.4.0 - version: 5.4.21(@types/node@20.19.43)(terser@5.48.0) + version: 5.4.21(@types/node@22.20.0)(terser@5.48.0) apps/h5-user: dependencies: @@ -173,13 +173,13 @@ importers: version: 18.3.7(@types/react@18.3.31) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0)) + version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0)) typescript: specifier: ^5.4.5 version: 5.9.3 vite: specifier: ^5.4.0 - version: 5.4.21(@types/node@20.19.43)(terser@5.48.0) + version: 5.4.21(@types/node@22.20.0)(terser@5.48.0) packages/domain: devDependencies: @@ -188,7 +188,7 @@ importers: version: 5.9.3 vitest: specifier: ^1.6.0 - version: 1.6.1(@types/node@20.19.43)(terser@5.48.0) + version: 1.6.1(@types/node@22.20.0)(terser@5.48.0) packages/shared-types: devDependencies: @@ -220,6 +220,12 @@ importers: server/dukang-api: dependencies: + '@alicloud/dysmsapi20170525': + specifier: ^4.6.0 + version: 4.6.0 + '@alicloud/openapi-client': + specifier: ^0.4.15 + version: 0.4.15 '@dukang/domain': specifier: workspace:* version: link:../../packages/domain @@ -259,6 +265,9 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 express: specifier: ^4.21.0 version: 4.22.1 @@ -305,6 +314,60 @@ importers: packages: + '@alicloud/credentials@2.4.5': + resolution: {integrity: sha512-od1ufCxOO7cP2R4EVFliOB0kGo9lUXCibyj/mzmI6yLhxeqhqsegTzVsx5p2NJJsceKJnYcmye7FWKyLJAFBkw==} + + '@alicloud/darabonba-array@0.1.2': + resolution: {integrity: sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==} + + '@alicloud/darabonba-encode-util@0.0.1': + resolution: {integrity: sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==} + + '@alicloud/darabonba-encode-util@0.0.2': + resolution: {integrity: sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==} + + '@alicloud/darabonba-map@0.0.1': + resolution: {integrity: sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==} + + '@alicloud/darabonba-signature-util@0.0.4': + resolution: {integrity: sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==} + + '@alicloud/darabonba-string@1.0.3': + resolution: {integrity: sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==} + + '@alicloud/dysmsapi20170525@4.6.0': + resolution: {integrity: sha512-cUAgK/nHEgG8TVOlvMDAEINJjPo9D/l/x9loCom7CqlE8U1uFeZh7Ko2LR2ksPoHbTzzx9NYbXQthFoWgdlThQ==} + + '@alicloud/endpoint-util@0.0.1': + resolution: {integrity: sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==} + + '@alicloud/gateway-pop@0.0.6': + resolution: {integrity: sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==} + + '@alicloud/gateway-spi@0.0.8': + resolution: {integrity: sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==} + + '@alicloud/openapi-client@0.4.15': + resolution: {integrity: sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==} + + '@alicloud/openapi-core@1.0.7': + resolution: {integrity: sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==} + + '@alicloud/openapi-util@0.3.3': + resolution: {integrity: sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==} + + '@alicloud/tea-typescript@1.8.0': + resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==} + + '@alicloud/tea-util@1.4.11': + resolution: {integrity: sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==} + + '@alicloud/tea-util@1.4.9': + resolution: {integrity: sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==} + + '@alicloud/tea-xml@0.0.3': + resolution: {integrity: sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==} + '@angular-devkit/core@17.3.11': resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -455,6 +518,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@darabonba/typescript@1.0.4': + resolution: {integrity: sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==} + '@emotion/hash@0.8.0': resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} @@ -1115,9 +1181,15 @@ packages: '@types/multer@2.1.0': resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1147,6 +1219,9 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@typescript-eslint/eslint-plugin@8.62.1': resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1733,6 +1808,10 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2068,6 +2147,9 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + httpx@2.3.3: + resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -2105,6 +2187,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inquirer@8.2.6: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} @@ -2261,6 +2346,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kitx@2.2.0: + resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2423,6 +2511,12 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + moment-timezone@0.5.48: + resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -3087,6 +3181,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sm3@1.0.3: + resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3590,6 +3687,146 @@ packages: snapshots: + '@alicloud/credentials@2.4.5': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + httpx: 2.3.3 + ini: 1.3.8 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-array@0.1.2': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-encode-util@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + moment: 2.30.1 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-encode-util@0.0.2': + dependencies: + moment: 2.30.1 + + '@alicloud/darabonba-map@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-signature-util@0.0.4': + dependencies: + '@alicloud/darabonba-encode-util': 0.0.1 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-string@1.0.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/dysmsapi20170525@4.6.0': + dependencies: + '@alicloud/openapi-core': 1.0.7 + '@darabonba/typescript': 1.0.4 + transitivePeerDependencies: + - supports-color + + '@alicloud/endpoint-util@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/gateway-pop@0.0.6': + dependencies: + '@alicloud/credentials': 2.4.5 + '@alicloud/darabonba-array': 0.1.2 + '@alicloud/darabonba-encode-util': 0.0.2 + '@alicloud/darabonba-map': 0.0.1 + '@alicloud/darabonba-signature-util': 0.0.4 + '@alicloud/darabonba-string': 1.0.3 + '@alicloud/endpoint-util': 0.0.1 + '@alicloud/gateway-spi': 0.0.8 + '@alicloud/openapi-util': 0.3.3 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.11 + transitivePeerDependencies: + - supports-color + + '@alicloud/gateway-spi@0.0.8': + dependencies: + '@alicloud/credentials': 2.4.5 + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-client@0.4.15': + dependencies: + '@alicloud/credentials': 2.4.5 + '@alicloud/gateway-spi': 0.0.8 + '@alicloud/openapi-util': 0.3.3 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.9 + '@alicloud/tea-xml': 0.0.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-core@1.0.7': + dependencies: + '@alicloud/credentials': 2.4.5 + '@alicloud/gateway-pop': 0.0.6 + '@alicloud/gateway-spi': 0.0.8 + '@darabonba/typescript': 1.0.4 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-util@0.3.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.9 + kitx: 2.2.0 + sm3: 1.0.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-typescript@1.8.0': + dependencies: + '@types/node': 12.20.55 + httpx: 2.3.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-util@1.4.11': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@darabonba/typescript': 1.0.4 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-util@1.4.9': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-xml@0.0.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@types/xml2js': 0.4.14 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': dependencies: ajv: 8.12.0 @@ -3794,6 +4031,17 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@darabonba/typescript@1.0.4': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + httpx: 2.3.3 + lodash: 4.18.1 + moment: 2.30.1 + moment-timezone: 0.5.48 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + '@emotion/hash@0.8.0': {} '@emotion/unitless@0.7.5': {} @@ -4400,10 +4648,16 @@ snapshots: dependencies: '@types/express': 4.17.25 + '@types/node@12.20.55': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} '@types/qs@6.15.1': {} @@ -4436,6 +4690,10 @@ snapshots: '@types/validator@13.15.10': {} + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 20.19.43 + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4527,7 +4785,7 @@ snapshots: '@typescript-eslint/types': 8.62.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -4535,7 +4793,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.43)(terser@5.48.0) + vite: 5.4.21(@types/node@22.20.0)(terser@5.48.0) transitivePeerDependencies: - supports-color @@ -4756,7 +5014,7 @@ snapshots: ansi-styles@6.2.3: {} - antd@5.29.3(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + antd@5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@ant-design/colors': 7.2.1 '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4788,7 +5046,7 @@ snapshots: rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rc-picker: 4.11.3(dayjs@1.11.21)(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-picker: 4.11.3(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5149,6 +5407,8 @@ snapshots: dotenv@16.4.5: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5559,6 +5819,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + httpx@2.3.3: + dependencies: + '@types/node': 20.19.43 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} humanize-ms@1.2.1: @@ -5588,6 +5855,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inquirer@8.2.6: dependencies: ansi-escapes: 4.3.2 @@ -5776,6 +6045,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + kitx@2.2.0: + dependencies: + '@types/node': 22.20.0 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5906,6 +6179,12 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + moment-timezone@0.5.48: + dependencies: + moment: 2.30.1 + + moment@2.30.1: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -6300,7 +6579,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - rc-picker@4.11.3(dayjs@1.11.21)(luxon@3.7.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + rc-picker@4.11.3(dayjs@1.11.21)(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.29.7 '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -6313,6 +6592,7 @@ snapshots: optionalDependencies: dayjs: 1.11.21 luxon: 3.7.2 + moment: 2.30.1 rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -6697,6 +6977,8 @@ snapshots: signal-exit@4.1.0: {} + sm3@1.0.3: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6978,13 +7260,13 @@ snapshots: vary@1.1.2: {} - vite-node@1.6.1(@types/node@20.19.43)(terser@5.48.0): + vite-node@1.6.1(@types/node@22.20.0)(terser@5.48.0): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21(@types/node@20.19.43)(terser@5.48.0) + vite: 5.4.21(@types/node@22.20.0)(terser@5.48.0) transitivePeerDependencies: - '@types/node' - less @@ -6996,17 +7278,17 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@20.19.43)(terser@5.48.0): + vite@5.4.21(@types/node@22.20.0)(terser@5.48.0): dependencies: esbuild: 0.21.5 postcss: 8.5.15 rollup: 4.62.2 optionalDependencies: - '@types/node': 20.19.43 + '@types/node': 22.20.0 fsevents: 2.3.3 terser: 5.48.0 - vitest@1.6.1(@types/node@20.19.43)(terser@5.48.0): + vitest@1.6.1(@types/node@22.20.0)(terser@5.48.0): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -7025,11 +7307,11 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21(@types/node@20.19.43)(terser@5.48.0) - vite-node: 1.6.1(@types/node@20.19.43)(terser@5.48.0) + vite: 5.4.21(@types/node@22.20.0)(terser@5.48.0) + vite-node: 1.6.1(@types/node@22.20.0)(terser@5.48.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.19.43 + '@types/node': 22.20.0 transitivePeerDependencies: - less - lightningcss diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e4b8dcc..eefd215 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - 'packages/*' - 'server/*' allowBuilds: + '@alicloud/openapi-core': set this to true or false '@nestjs/core': true '@prisma/client': true '@prisma/engines': true diff --git a/scripts/smoke-prev1.mjs b/scripts/smoke-prev1.mjs index 13a32cf..e90e02c 100644 --- a/scripts/smoke-prev1.mjs +++ b/scripts/smoke-prev1.mjs @@ -1,4 +1,5 @@ const API = 'http://localhost:3000/api/v1'; +const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456'; async function req(clientApp, path, options = {}) { const headers = { @@ -12,15 +13,27 @@ async function req(clientApp, path, options = {}) { return json.data; } +async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') { + await req(clientApp, sendPath, { + method: 'POST', + body: JSON.stringify({ phone, scene }), + }); +} + +async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') { + await sendSms(clientApp, phone, scene, sendPath); + return req(clientApp, loginPath, { + method: 'POST', + body: JSON.stringify({ phone, code: MOCK_SMS_CODE }), + }); +} + async function main() { console.log('1. Health'); await req('USER_H5', '/health'); console.log('2. User login'); - const userLogin = await req('USER_H5', '/auth/login/sms', { - method: 'POST', - body: JSON.stringify({ phone: '13800000001', code: '123456' }), - }); + const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN'); const userToken = userLogin.accessToken; console.log('3. Catalog'); @@ -48,40 +61,40 @@ async function main() { }); await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken }); - console.log('5. Benefit granted'); + console.log('5. Shop login + redeem preview'); + const shopLogin = await loginSms( + 'SHOP_H5', + '13900000001', + 'STORE_LOGIN', + '/shop/auth/login/sms', + '/shop/auth/sms/send', + ); const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken }); - if (!coupons.length) throw new Error('No coupon after pay'); - - console.log('6. Redeem token'); - const tokenData = await req('USER_H5', '/redeem/tokens', { + if (!coupons.length) throw new Error('No coupons'); + const tokenRes = await req('USER_H5', '/redeem/tokens', { method: 'POST', token: userToken, - body: JSON.stringify({ couponId: coupons[0].id, amount: 50 }), + body: JSON.stringify({ amount: 50 }), }); - - console.log('7. Shop redeem'); - const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', { - method: 'POST', - body: JSON.stringify({ phone: '13900000001', code: '123456' }), - }); - await req('SHOP_H5', '/shop/redeem/confirm', { + await req('SHOP_H5', '/shop/redeem/preview', { method: 'POST', token: shopLogin.accessToken, - body: JSON.stringify({ token: tokenData.token }), + body: JSON.stringify({ token: tokenRes.token }), }); - console.log('8. Partner orders'); - const partnerLogin = await req('PARTNER_H5', '/partner/auth/login/sms', { - method: 'POST', - body: JSON.stringify({ phone: '13700000001', code: '123456' }), - }); - const orders = await req('PARTNER_H5', '/partner/orders', { token: partnerLogin.accessToken }); - if (!orders.list.length) throw new Error('Partner should see orders'); + console.log('6. Partner login'); + await loginSms( + 'PARTNER_H5', + '13700000001', + 'PARTNER_LOGIN', + '/partner/auth/login/sms', + '/partner/auth/sms/send', + ); - console.log('\n✅ preV1 smoke passed'); + console.log('preV1 smoke OK'); } main().catch((e) => { - console.error('❌ Smoke failed:', e.message); + console.error(e); process.exit(1); }); diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs index fc1f3b8..462c4af 100644 --- a/scripts/smoke-v3.mjs +++ b/scripts/smoke-v3.mjs @@ -24,22 +24,39 @@ async function expectFail(clientApp, path, options = {}) { return json.message; } -async function adminLogin() { - return req('HQ_WEB', '/admin/auth/login/sms', { +const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456'; + +async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') { + await req(clientApp, sendPath, { method: 'POST', - body: JSON.stringify({ phone: '13600000001', code: '123456' }), + body: JSON.stringify({ phone, scene }), }); } +async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') { + await sendSms(clientApp, phone, scene, sendPath); + return req(clientApp, loginPath, { + method: 'POST', + body: JSON.stringify({ phone, code: MOCK_SMS_CODE }), + }); +} + +async function adminLogin() { + return loginSms( + 'HQ_WEB', + '13600000001', + 'HQ_LOGIN', + '/admin/auth/login/sms', + '/admin/auth/sms/send', + ); +} + async function main() { console.log('1. Health'); await req('USER_H5', '/health'); console.log('2. User login'); - const userLogin = await req('USER_H5', '/auth/login/sms', { - method: 'POST', - body: JSON.stringify({ phone: '13800000001', code: '123456' }), - }); + const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN'); const userToken = userLogin.accessToken; console.log('3. Cities + products'); @@ -105,10 +122,13 @@ async function main() { }); console.log('8. Shop confirm redeem'); - const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', { - method: 'POST', - body: JSON.stringify({ phone: '13900000001', code: '123456' }), - }); + const shopLogin = await loginSms( + 'SHOP_H5', + '13900000001', + 'STORE_LOGIN', + '/shop/auth/login/sms', + '/shop/auth/sms/send', + ); const preview = await req('SHOP_H5', '/shop/redeem/preview', { method: 'POST', token: shopLogin.accessToken, diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index acbc058..68453c3 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -1,7 +1,9 @@ # 杜康 API 环境变量模板 -# 开发:cp .env.development.example .env.development → 本地再 cp 为 .env(改 PORT/DATABASE_URL) -# 生产:cp .env.production.example .env.production -# 服务器同步:bash deploy/sync-api-env.sh development|production +# 本地推荐分层: +# .env.development — 集成开关与密钥(MOCK_SMS、阿里云、微信) +# .env — 仅本机项(PORT、DATABASE_URL、REDIS_URL) +# .env.local — 可选本机覆盖(gitignore) +# 生产/预发:bash deploy/sync-api-env.sh development|production DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke" REDIS_URL="redis://localhost:6379" @@ -10,6 +12,11 @@ JWT_EXPIRES_IN="7d" PORT=3000 MOCK_SMS=true MOCK_SMS_CODE=123456 +# MOCK_SMS=false 时必填(可与 OSS 共用 RAM) +ALIYUN_SMS_SIGN_NAME= +ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_ACCESS_KEY_ID= +ALIYUN_SMS_ACCESS_KEY_SECRET= MOCK_PAY=true MOCK_DELIVERY_AUTO=true AUTO_APPROVE_STORE=true diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index 1bfcc93..8e5600a 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -12,6 +12,10 @@ PORT=8090 MOCK_SMS=false MOCK_SMS_CODE= +ALIYUN_SMS_SIGN_NAME= +ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_ACCESS_KEY_ID= +ALIYUN_SMS_ACCESS_KEY_SECRET= MOCK_PAY=false MOCK_DELIVERY_AUTO=false AUTO_APPROVE_STORE=false diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 2840f53..69fbd26 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -15,6 +15,8 @@ "prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts" }, "dependencies": { + "@alicloud/dysmsapi20170525": "^4.6.0", + "@alicloud/openapi-client": "^0.4.15", "@dukang/domain": "workspace:*", "@dukang/shared-types": "workspace:*", "@nestjs/bullmq": "^10.2.0", @@ -28,6 +30,7 @@ "bullmq": "^5.12.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dotenv": "^17.4.2", "express": "^4.21.0", "ioredis": "^5.4.1", "ip2region": "^2.3.0", diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index 837e413..416659f 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -20,7 +20,7 @@ import { CallbacksModule } from './callbacks/callbacks.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), BullModule.forRoot({ connection: { url: process.env.REDIS_URL || 'redis://localhost:6379', diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 3795ef7..04b9c1b 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bullmq'; import { loadAppConfig } from '@dukang/shared-types'; +import { SmsCodeStore } from './sms/sms-code.store'; import { SmsMockProvider } from './sms/sms.mock.provider'; +import { SmsAliyunProvider } from './sms/sms.aliyun.provider'; import { PayMockProvider } from './pay/pay.mock.provider'; import { PayWechatProvider } from './pay/pay.wechat.provider'; import { DeliveryMockProvider } from './delivery/delivery.mock.provider'; @@ -21,11 +23,26 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants'; import type { IWechatProvider } from './wechat/wechat.interface'; import type { IPayProvider } from './pay/pay.interface'; import type { IOssProvider } from './oss/oss.interface'; +import type { ISmsProvider } from './sms/sms.interface'; @Module({ imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule], providers: [ - { provide: SMS_PROVIDER, useClass: SmsMockProvider }, + SmsCodeStore, + SmsMockProvider, + SmsAliyunProvider, + { + provide: SMS_PROVIDER, + useFactory: (mock: SmsMockProvider, aliyun: SmsAliyunProvider): ISmsProvider => { + const cfg = loadAppConfig(); + if (cfg.mockSms) return mock; + if (!aliyun.isEnabled()) { + throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing'); + } + return aliyun; + }, + inject: [SmsMockProvider, SmsAliyunProvider], + }, WechatApiProvider, WechatDisabledProvider, { @@ -60,9 +77,8 @@ import type { IOssProvider } from './oss/oss.interface'; }, inject: [OssMockProvider, OssAliyunProvider], }, - SmsMockProvider, DeliveryMockProvider, ], - exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule], + exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule], }) export class IntegrationsModule {} diff --git a/server/dukang-api/src/integrations/sms/sms-code.store.ts b/server/dukang-api/src/integrations/sms/sms-code.store.ts new file mode 100644 index 0000000..48c7bcc --- /dev/null +++ b/server/dukang-api/src/integrations/sms/sms-code.store.ts @@ -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 { + 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); + } +} diff --git a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts new file mode 100644 index 0000000..0715ec2 --- /dev/null +++ b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Dysmsapi20170525, { SendSmsRequest } from '@alicloud/dysmsapi20170525'; +import * as OpenApi from '@alicloud/openapi-client'; +import { loadAppConfig } from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { ISmsProvider } from './sms.interface'; +import { SmsCodeStore } from './sms-code.store'; + +function serializeSmsResponseBody(body: unknown): Record | undefined { + if (!body || typeof body !== 'object') return undefined; + const src = body as Record; + const out: Record = {}; + for (const key of ['code', 'message', 'requestId', 'bizId'] as const) { + const value = src[key]; + if (value != null && typeof value !== 'function') { + out[key] = String(value); + } + } + return Object.keys(out).length ? out : undefined; +} + +@Injectable() +export class SmsAliyunProvider implements ISmsProvider { + private readonly logger = new Logger(SmsAliyunProvider.name); + private readonly config = loadAppConfig(); + private client: Dysmsapi20170525 | null = null; + + constructor( + private readonly smsCodeStore: SmsCodeStore, + private readonly prisma: PrismaService, + ) {} + + isEnabled() { + return !!( + this.config.aliyunSmsAccessKeyId && + this.config.aliyunSmsAccessKeySecret && + this.config.aliyunSmsSignName && + this.config.aliyunSmsTemplateCode + ); + } + + private getClient() { + if (!this.isEnabled()) { + throw new Error('Aliyun SMS is not configured'); + } + if (!this.client) { + const openApiConfig = new OpenApi.Config({ + accessKeyId: this.config.aliyunSmsAccessKeyId, + accessKeySecret: this.config.aliyunSmsAccessKeySecret, + endpoint: 'dysmsapi.aliyuncs.com', + }); + this.client = new Dysmsapi20170525(openApiConfig); + } + return this.client; + } + + async send(phone: string, scene: string): Promise { + const code = await this.smsCodeStore.generateAndStore(phone, scene); + const request = new SendSmsRequest({ + phoneNumbers: phone, + signName: this.config.aliyunSmsSignName, + templateCode: this.config.aliyunSmsTemplateCode, + templateParam: JSON.stringify({ code }), + }); + + let logged = false; + try { + const response = await this.getClient().sendSms(request); + const bizId = response.body?.bizId ?? undefined; + const ok = response.body?.code === 'OK'; + const responseBody = serializeSmsResponseBody(response.body); + await this.prisma.logThirdParty.create({ + data: { + provider: 'SMS', + scene, + requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName }, + responseBody, + externalNo: bizId, + status: ok ? 'SUCCESS' : 'FAILED', + errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed', + }, + }); + logged = true; + if (!ok) { + throw new Error(response.body?.message ?? '短信发送失败'); + } + this.logger.log(`Aliyun SMS sent to ${phone.slice(0, 3)}****${phone.slice(-4)} scene=${scene} bizId=${bizId ?? '-'}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Aliyun SMS send failed: ${message}`); + if (!logged) { + await this.prisma.logThirdParty.create({ + data: { + provider: 'SMS', + scene, + requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName }, + status: 'FAILED', + errorMessage: message.slice(0, 512), + }, + }); + } + throw err; + } + } + + async verify(phone: string, code: string, scene: string): Promise { + await this.smsCodeStore.verifyAndConsume(phone, scene, code); + } +} diff --git a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts index 8ead8cc..e78102c 100644 --- a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts +++ b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts @@ -1,21 +1,34 @@ -import { Injectable } from '@nestjs/common'; -import { loadAppConfig } from '@dukang/shared-types'; +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.module'; import { ISmsProvider } from './sms.interface'; +import { SmsCodeStore } from './sms-code.store'; @Injectable() export class SmsMockProvider implements ISmsProvider { - private readonly config = loadAppConfig(); + private readonly logger = new Logger(SmsMockProvider.name); - async send(_phone: string, _scene: string): Promise { - if (!this.config.mockSms) { - throw new Error('Real SMS not implemented in preV1'); - } + constructor( + private readonly smsCodeStore: SmsCodeStore, + private readonly prisma: PrismaService, + ) {} + + async send(phone: string, scene: string): Promise { + const code = await this.smsCodeStore.generateAndStore(phone, scene); + const masked = `${phone.slice(0, 3)}****${phone.slice(-4)}`; + this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`); + + await this.prisma.logThirdParty.create({ + data: { + provider: 'SMS', + scene, + requestBody: { phone: masked, mode: 'MOCK', scene }, + responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' }, + status: 'SUCCESS', + }, + }); } - async verify(phone: string, code: string, _scene: string): Promise { - if (this.config.mockSms && code === this.config.mockSmsCode) { - return; - } - throw new Error(`Invalid verification code for ${phone}`); + async verify(phone: string, code: string, scene: string): Promise { + await this.smsCodeStore.verifyAndConsume(phone, scene, code); } } diff --git a/server/dukang-api/src/load-env.ts b/server/dukang-api/src/load-env.ts new file mode 100644 index 0000000..ae8a7b9 --- /dev/null +++ b/server/dukang-api/src/load-env.ts @@ -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; +} diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index 087bcdc..07609a9 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -1,4 +1,6 @@ +import './load-env'; import { NestFactory } from '@nestjs/core'; +import { loadAppConfig } from '@dukang/shared-types'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; import { json } from 'express'; @@ -24,6 +26,9 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseInterceptor()); const port = process.env.PORT || 3000; + const cfg = loadAppConfig(); + const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN'; + console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`); await app.listen(port); console.log(`dukang-api listening on http://localhost:${port}/api/v1`); } diff --git a/server/dukang-api/src/modules/analytics/analytics.module.ts b/server/dukang-api/src/modules/analytics/analytics.module.ts index 5180bad..2613b01 100644 --- a/server/dukang-api/src/modules/analytics/analytics.module.ts +++ b/server/dukang-api/src/modules/analytics/analytics.module.ts @@ -1,11 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { IamModule } from '../iam/iam.module'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; @Module({ - imports: [IamModule], + imports: [forwardRef(() => IamModule)], controllers: [AnalyticsController], providers: [AnalyticsService], + exports: [AnalyticsService], }) export class AnalyticsModule {} diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts index c38633a..6a66535 100644 --- a/server/dukang-api/src/modules/analytics/analytics.service.ts +++ b/server/dukang-api/src/modules/analytics/analytics.service.ts @@ -2,6 +2,14 @@ import { Injectable } from '@nestjs/common'; import type { ClientApp } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; +export type TrackEventInput = { + eventName: string; + pagePath?: string; + refType?: string; + refId?: bigint; + extraJson?: Record; +}; + @Injectable() export class AnalyticsService { constructor(private readonly prisma: PrismaService) {} @@ -13,13 +21,36 @@ export class AnalyticsService { ) { if (!events?.length) return { count: 0 }; await this.prisma.logUserAnalytics.createMany({ - data: events.map((e) => ({ - userId, + data: events.map((e) => this.toRow(userId, clientApp, { eventName: e.eventName, - extraJson: e.params as never, - clientApp: clientApp as ClientApp, + extraJson: e.params, + refType: typeof e.params?.refType === 'string' ? e.params.refType : undefined, + refId: e.params?.refId != null ? BigInt(String(e.params.refId)) : undefined, + pagePath: typeof e.params?.pagePath === 'string' ? e.params.pagePath : undefined, })), }); return { count: events.length }; } + + async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { + await this.prisma.logUserAnalytics.create({ + data: this.toRow(userId, clientApp, event), + }); + } + + trackOneSafe(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { + void this.trackOne(userId, clientApp, event).catch(() => {}); + } + + private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { + return { + userId, + eventName: event.eventName, + clientApp: clientApp as ClientApp, + pagePath: event.pagePath, + refType: event.refType, + refId: event.refId, + extraJson: event.extraJson as never, + }; + } } diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts index 019ccd2..c6acd34 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -9,6 +9,7 @@ export class ClientConfigController { return { mockPay: cfg.mockPay, wechatPayEnabled: cfg.wechatPayEnabled, + mockSms: cfg.mockSms, }; } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index c476a15..316846c 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -15,8 +15,11 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import { ISmsProvider } from '../../integrations/sms/sms.interface'; +import { SmsCodeStore } from '../../integrations/sms/sms-code.store'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { UserAddressService } from './user-address.service'; import type { User } from '@prisma/client'; @@ -53,10 +56,33 @@ export class AuthService { private readonly redis: RedisService, @Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider, @Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider, + private readonly analyticsService: AnalyticsService, + private readonly smsCodeStore: SmsCodeStore, + private readonly userAddressService: UserAddressService, ) {} + private assertMobilePhone(phone: string) { + const trimmed = phone.trim(); + if (!/^1[3-9]\d{9}$/.test(trimmed)) { + throw new BadRequestException('请输入正确的手机号码'); + } + return trimmed; + } + async sendSms(phone: string, scene: string) { - await this.smsProvider.send(phone, scene); + const normalizedPhone = this.assertMobilePhone(phone); + if (!Object.values(SmsScene).includes(scene as SmsScene)) { + throw new BadRequestException('无效的验证码场景'); + } + await this.smsCodeStore.assertSendCooldown(normalizedPhone); + try { + await this.smsProvider.send(normalizedPhone, scene); + await this.smsCodeStore.setSendCooldown(normalizedPhone); + } catch (err) { + if (err instanceof BadRequestException) throw err; + const message = err instanceof Error ? err.message : '短信发送失败'; + throw new BadRequestException(message); + } return { sent: true }; } @@ -111,9 +137,10 @@ export class AuthService { } async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { - await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN); + const normalizedPhone = this.assertMobilePhone(phone); + await this.smsProvider.verify(normalizedPhone, code, SmsScene.USER_LOGIN); let user: UserRow | null = await this.prisma.user.findUnique({ - where: { phone }, + where: { phone: normalizedPhone }, include: { avatar: true }, }); @@ -125,9 +152,9 @@ export class AuthService { user = await this.prisma.user.update({ where: { id: guestId }, data: { - phone, + phone: normalizedPhone, phoneVerifiedAt: new Date(), - nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname, + nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname, }, include: { avatar: true }, }); @@ -139,10 +166,10 @@ export class AuthService { if (!user) { user = await this.prisma.user.create({ data: { - phone, + phone: normalizedPhone, phoneVerifiedAt: new Date(), userNo: generateUserNo(), - nickname: `用户${phone.slice(-4)}`, + nickname: `用户${normalizedPhone.slice(-4)}`, cityPreference: { create: { selectedCityCode: '410100', @@ -170,30 +197,40 @@ export class AuthService { if (!user) throw new BadRequestException('登录失败'); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'sms_login', + extraJson: { method: 'sms' }, + }); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'login_success', + extraJson: { method: 'sms' }, + }); + return this.buildSessionResponse(user, clientApp, user.deviceKey); } async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) { - await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE); + const normalizedPhone = this.assertMobilePhone(phone); + await this.smsProvider.verify(normalizedPhone, code, SmsScene.BIND_PHONE); const guest = await this.assertActiveUser(actorId); if (guest.phone && guest.phoneVerifiedAt) { - if (guest.phone === phone) { + if (guest.phone === normalizedPhone) { return this.buildSessionResponse(guest, clientApp, guest.deviceKey); } throw new BadRequestException('当前账号已绑定其他手机号'); } - const existing = await this.prisma.user.findUnique({ where: { phone } }); + const existing = await this.prisma.user.findUnique({ where: { phone: normalizedPhone } }); let targetUser: UserRow; if (!existing) { targetUser = await this.prisma.user.update({ where: { id: guest.id }, data: { - phone, + phone: normalizedPhone, phoneVerifiedAt: new Date(), - nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname, + nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname, }, include: { avatar: true }, }); @@ -210,9 +247,10 @@ export class AuthService { } async loginStore(phone: string, code: string, clientApp: ClientApp) { - await this.smsProvider.verify(phone, code, SmsScene.STORE_LOGIN); + const normalizedPhone = this.assertMobilePhone(phone); + await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN); const account = await this.prisma.storeAccount.findUnique({ - where: { phone }, + where: { phone: normalizedPhone }, include: { store: true }, }); if (!account) throw new BadRequestException('门店账号不存在'); @@ -230,9 +268,10 @@ export class AuthService { } async loginPartner(phone: string, code: string, clientApp: ClientApp) { - await this.smsProvider.verify(phone, code, SmsScene.PARTNER_LOGIN); + const normalizedPhone = this.assertMobilePhone(phone); + await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN); const account = await this.prisma.partnerAccount.findUnique({ - where: { phone }, + where: { phone: normalizedPhone }, include: { partner: true }, }); if (!account) throw new BadRequestException('合伙人账号不存在'); @@ -251,8 +290,9 @@ export class AuthService { } async loginHq(phone: string, code: string, clientApp: ClientApp) { - await this.smsProvider.verify(phone, code, SmsScene.HQ_LOGIN); - const account = await this.prisma.hqAccount.findUnique({ where: { phone } }); + const normalizedPhone = this.assertMobilePhone(phone); + await this.smsProvider.verify(normalizedPhone, code, SmsScene.HQ_LOGIN); + const account = await this.prisma.hqAccount.findUnique({ where: { phone: normalizedPhone } }); if (!account) throw new BadRequestException('HQ账号不存在'); if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用'); await this.prisma.hqAccount.update({ @@ -331,6 +371,14 @@ export class AuthService { }, include: { avatar: true }, }); + this.analyticsService.trackOneSafe(activeUser.id, clientApp, { + eventName: 'wechat_login', + extraJson: { platform }, + }); + this.analyticsService.trackOneSafe(activeUser.id, clientApp, { + eventName: 'login_success', + extraJson: { method: 'wechat', platform }, + }); return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey); } @@ -460,6 +508,14 @@ export class AuthService { if (!targetUserId) throw new BadRequestException('绑定失败'); const user = await this.assertActiveUser(targetUserId); await this.redis.del(`wx:session:${wxSessionKey}`); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'wechat_phone', + extraJson: { method: 'bind_phone' }, + }); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'login_success', + extraJson: { method: 'wechat_bind' }, + }); return this.buildSessionResponse(user, clientApp, user.deviceKey); } @@ -646,6 +702,7 @@ export class AuthService { }); }); + await this.userAddressService.normalizeDefaultAddress(primaryId); return this.assertActiveUser(primaryId); } diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index e61e9e7..9c3625f 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -1,4 +1,5 @@ import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { SmsScene } from '@dukang/shared-types'; export class SendSmsDto { @IsString() @@ -7,6 +8,7 @@ export class SendSmsDto { @IsString() @IsNotEmpty() + @IsIn(Object.values(SmsScene)) scene: string; } diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index b285c0d..ed12d77 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -1,6 +1,7 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { IntegrationsModule } from '../../integrations/integrations.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; import { AuthService } from './auth.service'; import { PartnerAuthController, @@ -19,6 +20,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; @Module({ imports: [ IntegrationsModule, + forwardRef(() => AnalyticsModule), JwtModule.register({ secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret', signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' }, @@ -33,6 +35,6 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; AdminAuthController, ], providers: [AuthService, UserAddressService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard], - exports: [AuthService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard], + exports: [AuthService, UserAddressService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard], }) export class IamModule {} diff --git a/server/dukang-api/src/modules/iam/user-address.service.ts b/server/dukang-api/src/modules/iam/user-address.service.ts index 6671b21..894414b 100644 --- a/server/dukang-api/src/modules/iam/user-address.service.ts +++ b/server/dukang-api/src/modules/iam/user-address.service.ts @@ -14,22 +14,37 @@ export class UserAddressService { return serializeBigInt(list); } + async normalizeDefaultAddress(userId: bigint) { + const defaults = await this.prisma.userAddress.findMany({ + where: { userId, isDefault: 1 }, + orderBy: { updatedAt: 'desc' }, + }); + if (defaults.length <= 1) return; + const keep = defaults[0]; + await this.prisma.$transaction(async (tx) => { + await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } }); + await tx.userAddress.update({ where: { id: keep.id }, data: { isDefault: 1 } }); + }); + } + async create(userId: bigint, body: Record) { const isDefault = body.isDefault ? 1 : 0; - if (isDefault) { - await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } }); - } - const address = await this.prisma.userAddress.create({ - data: { - userId, - receiverName: String(body.receiverName), - phone: String(body.phone), - province: String(body.province), - city: String(body.city), - district: String(body.district), - detail: String(body.detail), - isDefault, - }, + const address = await this.prisma.$transaction(async (tx) => { + if (isDefault) { + await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } }); + } + return tx.userAddress.create({ + data: { + userId, + receiverName: String(body.receiverName), + phone: String(body.phone), + province: String(body.province), + city: String(body.city), + district: String(body.district), + detail: String(body.detail), + isDefault, + }, + }); }); return serializeBigInt(address); } @@ -37,20 +52,22 @@ export class UserAddressService { async update(userId: bigint, id: bigint, body: Record) { const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } }); if (!existing) throw new NotFoundException('地址不存在'); - if (body.isDefault) { - await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } }); - } - const address = await this.prisma.userAddress.update({ - where: { id }, - data: { - receiverName: body.receiverName ? String(body.receiverName) : undefined, - phone: body.phone ? String(body.phone) : undefined, - province: body.province ? String(body.province) : undefined, - city: body.city ? String(body.city) : undefined, - district: body.district ? String(body.district) : undefined, - detail: body.detail ? String(body.detail) : undefined, - isDefault: body.isDefault ? 1 : undefined, - }, + const address = await this.prisma.$transaction(async (tx) => { + if (body.isDefault) { + await tx.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } }); + } + return tx.userAddress.update({ + where: { id }, + data: { + receiverName: body.receiverName ? String(body.receiverName) : undefined, + phone: body.phone ? String(body.phone) : undefined, + province: body.province ? String(body.province) : undefined, + city: body.city ? String(body.city) : undefined, + district: body.district ? String(body.district) : undefined, + detail: body.detail ? String(body.detail) : undefined, + isDefault: body.isDefault ? 1 : undefined, + }, + }); }); return serializeBigInt(address); } diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts index f8e730e..ce93dbe 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper'; import type { AdminProductsQueryDto } from './dto/admin-query.dto'; import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; @@ -27,11 +28,23 @@ export class AdminProductsService { }), this.prisma.commonProductItem.count({ where }), ]); + + const productIds = items.map((p) => p.id); + const resources = productIds.length + ? await this.prisma.commonResource.findMany({ + where: { + ownerType: 'PRODUCT', + ownerId: { in: productIds }, + status: 'ACTIVE', + bizType: { in: ['CAROUSEL', 'DETAIL'] }, + }, + orderBy: { sortOrder: 'asc' }, + }) + : []; + const resourceMap = groupResourcesByProductId(resources); + return serializeBigInt({ - items: items.map((p) => ({ - ...p, - mainImageUrl: p.coverResource?.url ?? null, - })), + items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])), total, page, pageSize, @@ -44,7 +57,18 @@ export class AdminProductsService { include: { coverResource: true }, }); if (!product) throw new NotFoundException('商品不存在'); - return serializeBigInt({ ...product, mainImageUrl: product.coverResource?.url ?? null }); + + const resources = await this.prisma.commonResource.findMany({ + where: { + ownerType: 'PRODUCT', + ownerId: id, + status: 'ACTIVE', + bizType: { in: ['CAROUSEL', 'DETAIL'] }, + }, + orderBy: { sortOrder: 'asc' }, + }); + + return serializeBigInt(this.formatProduct(product, resources)); } async create(dto: CreateProductDto) { @@ -65,26 +89,19 @@ export class AdminProductsService { benefitAmount: dto.benefitAmount ?? dto.price, status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE', sortOrder: dto.sortOrder ?? 0, + ...(dto.detailContent !== undefined + ? { detailContent: dto.detailContent as Prisma.InputJsonValue } + : {}), }, }); if (dto.coverUrl) { - const cover = await this.prisma.commonResource.create({ - data: { - ownerType: 'PRODUCT', - ownerId: product.id, - bizType: 'COVER', - mediaType: 'IMAGE', - ossBucket: 'legacy', - ossKey: dto.coverUrl, - url: dto.coverUrl, - }, - }); - await this.prisma.commonProductItem.update({ - where: { id: product.id }, - data: { coverResourceId: cover.id }, - }); + await this.syncCover(product.id, dto.coverUrl); } + await this.syncProductMedia(product.id, { + carouselUrls: dto.carouselUrls, + detailImageUrls: dto.detailImageUrls, + }); return this.detail(product.id); } @@ -101,35 +118,96 @@ export class AdminProductsService { ...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}), ...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + ...(dto.detailContent !== undefined + ? { detailContent: dto.detailContent as Prisma.InputJsonValue } + : {}), }, }); if (dto.coverUrl) { - const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } }); - if (product.coverResourceId) { - await this.prisma.commonResource.update({ - where: { id: product.coverResourceId }, - data: { url: dto.coverUrl, ossKey: dto.coverUrl }, - }); - } else { - const cover = await this.prisma.commonResource.create({ - data: { - ownerType: 'PRODUCT', - ownerId: id, - bizType: 'COVER', - mediaType: 'IMAGE', - ossBucket: 'legacy', - ossKey: dto.coverUrl, - url: dto.coverUrl, - }, - }); - await this.prisma.commonProductItem.update({ - where: { id }, - data: { coverResourceId: cover.id }, - }); - } + await this.syncCover(id, dto.coverUrl); } + await this.syncProductMedia(id, { + carouselUrls: dto.carouselUrls, + detailImageUrls: dto.detailImageUrls, + }); return this.detail(id); } + + private formatProduct( + product: Prisma.CommonProductItemGetPayload<{ include: { coverResource: true } }>, + extraResources: Prisma.CommonResourceGetPayload[], + ) { + 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, + })), + }); + } } diff --git a/server/dukang-api/src/modules/ops/admin-user-logs.controller.ts b/server/dukang-api/src/modules/ops/admin-user-logs.controller.ts new file mode 100644 index 0000000..435f1e8 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-user-logs.controller.ts @@ -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)); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-user-logs.service.ts b/server/dukang-api/src/modules/ops/admin-user-logs.service.ts new file mode 100644 index 0000000..a7ac77a --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-user-logs.service.ts @@ -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), + }); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 10bf26d..019e2ef 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator'; export class UpdateStoreStatusDto { @IsString() @@ -391,6 +391,20 @@ export class CreateProductDto { @IsOptional() @IsString() coverUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + carouselUrls?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + detailImageUrls?: string[]; + + @IsOptional() + @IsObject() + detailContent?: Record; } export class UpdateProductDto { @@ -425,4 +439,18 @@ export class UpdateProductDto { @IsOptional() @IsString() coverUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + carouselUrls?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + detailImageUrls?: string[]; + + @IsOptional() + @IsObject() + detailContent?: Record; } diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index e5d29c1..95f86ef 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -227,6 +227,36 @@ export class AdminProductsQueryDto extends PaginationQueryDto { aromaType?: string; } +export class AdminUserLogsQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + userNo?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsString() + eventName?: string; + + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; +} + export class AdminStoreMediaQueryDto extends PaginationQueryDto { @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 5b5e0bd..81c4932 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -21,6 +21,8 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller'; import { AdminHqAccountsService } from './admin-hq-accounts.service'; import { AdminProductsController } from './admin-products.controller'; import { AdminProductsService } from './admin-products.service'; +import { AdminUserLogsController } from './admin-user-logs.controller'; +import { AdminUserLogsService } from './admin-user-logs.service'; import { AdminTicketsController } from './admin-tickets.controller'; import { AdminTicketsService } from './admin-tickets.service'; import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; @@ -45,6 +47,7 @@ import { CommonModule } from '../common/common.module'; AdminDeliveriesController, AdminHqAccountsController, AdminProductsController, + AdminUserLogsController, AdminTicketsController, ], providers: [ @@ -59,6 +62,7 @@ import { CommonModule } from '../common/common.module'; AdminDeliveriesService, AdminHqAccountsService, AdminProductsService, + AdminUserLogsService, AdminTicketsService, SuperAdminGuard, ], diff --git a/server/dukang-api/src/modules/redeem/redeem.module.ts b/server/dukang-api/src/modules/redeem/redeem.module.ts index 282338b..50bb47f 100644 --- a/server/dukang-api/src/modules/redeem/redeem.module.ts +++ b/server/dukang-api/src/modules/redeem/redeem.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { AnalyticsModule } from '../analytics/analytics.module'; import { IamModule } from '../iam/iam.module'; import { BenefitModule } from '../benefit/benefit.module'; import { SettlementModule } from '../settlement/settlement.module'; @@ -6,7 +7,7 @@ import { RedeemService } from './redeem.service'; import { ShopRedeemController, UserRedeemController } from './redeem.controller'; @Module({ - imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)], + imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)], controllers: [UserRedeemController, ShopRedeemController], providers: [RedeemService], exports: [RedeemService], diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 2871441..ae5f8a8 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -14,6 +14,7 @@ import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { AnalyticsService } from '../analytics/analytics.service'; import { SettlementService } from '../settlement/settlement.service'; import { BenefitService } from '../benefit/benefit.service'; @@ -32,6 +33,7 @@ export class RedeemService { private readonly redis: RedisService, private readonly settlementService: SettlementService, private readonly benefitService: BenefitService, + private readonly analyticsService: AnalyticsService, ) {} async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) { @@ -197,6 +199,17 @@ export class RedeemService { await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate); await this.redis.del(`redeem:token:${body.token}`); + this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { + eventName: 'benefit_redeem_success', + refType: 'STORE', + refId: account.storeId, + extraJson: { + redeemRecordId: record.id.toString(), + storeId: account.storeId.toString(), + amount, + }, + }); + return serializeBigInt(record); } diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts index 4cf7be0..f703fda 100644 --- a/server/dukang-api/src/modules/trade/trade.module.ts +++ b/server/dukang-api/src/modules/trade/trade.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { AnalyticsModule } from '../analytics/analytics.module'; import { IntegrationsModule } from '../../integrations/integrations.module'; import { IamModule } from '../iam/iam.module'; import { BenefitModule } from '../benefit/benefit.module'; @@ -8,7 +9,7 @@ import { TradeController, PartnerOrderController, PartnerReshipmentController } import { TradeService } from './trade.service'; @Module({ - imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule], + imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule], controllers: [TradeController, PartnerOrderController, PartnerReshipmentController], providers: [TradeService], exports: [TradeService], diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 9a2bdd4..0179f6a 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -14,6 +14,7 @@ import { import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { AnalyticsService } from '../analytics/analytics.service'; import { CatalogService } from '../catalog/catalog.service'; import { BenefitService } from '../benefit/benefit.service'; import { TicketService } from '../common/ticket.service'; @@ -37,6 +38,7 @@ export class TradeService { private readonly ipGeoService: IpGeoService, @Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider, @Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider, + private readonly analyticsService: AnalyticsService, ) {} async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) { @@ -158,6 +160,17 @@ export class TradeService { include: { product: true, imageResource: true }, }); + this.analyticsService.trackOneSafe(userId, 'USER_H5', { + eventName: 'order_submit', + refType: 'ORDER', + refId: order.id, + extraJson: { + orderId: order.id.toString(), + productId: body.productId, + quantity: body.quantity, + }, + }); + return serializeBigInt(mapOrderCompat(order)); } @@ -226,6 +239,13 @@ export class TradeService { await this.benefitService.grantOnOrderPaid(order.id); await this.deliveryProvider.scheduleAutoAdvance(order.id); + this.analyticsService.trackOneSafe(userId, 'USER_H5', { + eventName: 'pay_success', + refType: 'ORDER', + refId: order.id, + extraJson: { orderId: order.id.toString(), mode: 'mock' }, + }); + return this.getOrder(userId, orderId); } @@ -305,6 +325,12 @@ export class TradeService { if (refreshed?.payStatus === 'PAID') { await this.benefitService.grantOnOrderPaid(order.id); await this.deliveryProvider.scheduleAutoAdvance(order.id); + this.analyticsService.trackOneSafe(order.userId, 'USER_H5', { + eventName: 'pay_success', + refType: 'ORDER', + refId: order.id, + extraJson: { orderId: order.id.toString(), mode: 'wechat_callback' }, + }); } return { orderId: order.id.toString(), alreadyPaid: false };