+ {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