diff --git a/apps/mini-user/src/lib/product-fulfillment.ts b/apps/mini-user/src/lib/product-fulfillment.ts new file mode 100644 index 0000000..41c45ae --- /dev/null +++ b/apps/mini-user/src/lib/product-fulfillment.ts @@ -0,0 +1,45 @@ +/** 商品履约能力(与 HQ / 交易硬闸一致) */ + +export type FulfillmentFlags = { + allowOnlinePurchase?: boolean | null; + allowOnSitePickup?: boolean | null; + allowCrossCityDelivery?: boolean | null; +}; + +/** 未返回时默认允许线上(存量商品) */ +export function canBuyOnline(p: FulfillmentFlags): boolean { + return p.allowOnlinePurchase !== false; +} + +/** 仅显式开启才展示现场取货 */ +export function canPickupOnSite(p: FulfillmentFlags): boolean { + return p.allowOnSitePickup === true; +} + +export function canCrossCity(p: FulfillmentFlags): boolean { + return p.allowCrossCityDelivery !== false; +} + +export function normalizeFulfillmentFlags(p: T): T { + return { + ...p, + allowOnlinePurchase: canBuyOnline(p), + allowOnSitePickup: canPickupOnSite(p), + allowCrossCityDelivery: canCrossCity(p), + }; +} + +/** 与交易侧一致:收货市 ≠ 开城市且 ≠ 郑州 → 跨城 */ +export function isCrossCityAddress( + addressCity: string | null | undefined, + openCityName: string | null | undefined, +): boolean { + const addr = (addressCity || '').trim(); + const open = (openCityName || '').trim(); + if (!addr) return false; + if (addr === '郑州市') return false; + if (open && addr === open) return false; + // 尚无开城信息时,非郑州地址先按可能跨城处理(由预览接口最终裁定) + if (!open) return addr !== '郑州市'; + return true; +} diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index d41b2fb..dd9c7e5 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -17,6 +17,12 @@ import { isLoggedIn, request, toast } from '../../lib/api'; import { ensurePayReady } from '../../lib/pay-ready'; import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { getProductMainImage } from '../../lib/product-images'; +import { + canBuyOnline, + canCrossCity, + canPickupOnSite, + normalizeFulfillmentFlags, +} from '../../lib/product-fulfillment'; import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; import { DEFAULT_SHARE_DESC, @@ -50,9 +56,9 @@ const FULFILLMENT_FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [ function matchFulfillmentFilter(p: Product, filter: FulfillmentFilter): boolean { if (filter === 'ALL') return true; - if (filter === 'ONLINE') return p.allowOnlinePurchase !== false; - if (filter === 'CROSS_CITY') return p.allowCrossCityDelivery !== false; - return !!p.allowOnSitePickup; + if (filter === 'ONLINE') return canBuyOnline(p); + if (filter === 'CROSS_CITY') return canCrossCity(p); + return canPickupOnSite(p); } type MiniHomeConfig = { @@ -117,7 +123,9 @@ export default function HomePage() { const loadProducts = useCallback(() => { setLoading(true); return request(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) - .then((list) => setProducts(Array.isArray(list) ? list : [])) + .then((list) => + setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []), + ) .catch((e) => toast(e instanceof Error ? e.message : '加载失败')) .finally(() => setLoading(false)); }, [cityCode]); @@ -138,7 +146,7 @@ export default function HomePage() { request(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`), loadMiniHome(), ]); - setProducts(Array.isArray(list) ? list : []); + setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []); } catch (e) { toast(e instanceof Error ? e.message : '加载失败'); } finally { @@ -268,7 +276,7 @@ export default function HomePage() { - {p.allowOnSitePickup ? ( + {canPickupOnSite(p) ? ( { @@ -279,7 +287,7 @@ export default function HomePage() { 现场取货 ) : null} - {p.allowOnlinePurchase !== false ? ( + {canBuyOnline(p) ? ( { diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index f389c2c..2e665d0 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -9,7 +9,8 @@ import { tryGetClientGpsLocation } from '../../lib/client-location'; import { maskPhone } from '../../lib/phone'; import { ensurePayReady } from '../../lib/pay-ready'; import { fetchUserProfile } from '../../lib/pay-wechat'; -import { request } from '../../lib/api'; +import { request, toast } from '../../lib/api'; +import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment'; import { getProductMainImage } from '../../lib/product-images'; type Address = { @@ -31,6 +32,8 @@ type PreviewProduct = { price: number; mainImageUrl?: string | null; carouselUrls?: string[] | null; + allowCrossCityDelivery?: boolean; + allowOnlinePurchase?: boolean; }; type OrderPreview = { @@ -41,12 +44,18 @@ type OrderPreview = { freightPayType: 'COD' | null; payAmount: number; benefitAmount: number; - city?: { localMinQty: number; crossMinQty: number }; + city?: { name?: string; localMinQty: number; crossMinQty: number }; quantityOk?: boolean; quantityMessage?: string | null; + addressOk?: boolean; + addressMessage?: string | null; minQty?: number; + allowCrossCityDelivery?: boolean; + allowOnlinePurchase?: boolean; }; +const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址'; + function formatAddress(a: Address) { return `${a.province}${a.city}${a.district}${a.detail}`; } @@ -64,6 +73,7 @@ export default function OrderConfirmPage() { const [loading, setLoading] = useState(false); const [msg, setMsg] = useState(''); const phonePromptSkipped = useRef(false); + const toastedAddressBlockRef = useRef(''); useEffect(() => { request('/user/addresses') @@ -94,7 +104,24 @@ export default function OrderConfirmPage() { .then((data) => { if (!cancelled) { setPreview(data); - setMsg(data.quantityOk === false ? (data.quantityMessage || '') : ''); + const nextMsg = + data.addressOk === false + ? data.addressMessage || CROSS_CITY_BLOCK_MSG + : data.quantityOk === false + ? data.quantityMessage || '' + : ''; + setMsg(nextMsg); + if ( + data.addressOk === false && + addressId && + toastedAddressBlockRef.current !== addressId + ) { + toastedAddressBlockRef.current = addressId; + toast(data.addressMessage || CROSS_CITY_BLOCK_MSG); + } + if (data.addressOk !== false) { + toastedAddressBlockRef.current = ''; + } } }) .catch((e) => { @@ -117,12 +144,27 @@ export default function OrderConfirmPage() { [addresses, addressId], ); - const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY'; + const allowCross = + preview?.allowCrossCityDelivery !== undefined + ? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery }) + : canCrossCity(preview?.product ?? {}); + const localCross = + !!selectedAddress && + isCrossCityAddress(selectedAddress.city, preview?.city?.name); + const isCross = + forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross; + const crossBlocked = isCross && !allowCross; + const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked; const minQty = preview?.minQty ?? (isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2)); const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false; - const canSubmit = !!addressId && !!preview && quantityOk && !loading && !previewLoading; + const canSubmit = + !!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading; + + const addressHint = !addressOk + ? preview?.addressMessage || CROSS_CITY_BLOCK_MSG + : ''; function updateQuantity(next: number) { if (next < 1) return; @@ -162,6 +204,12 @@ export default function OrderConfirmPage() { setMsg('请选择收货地址'); return; } + if (!addressOk) { + const tip = addressHint || CROSS_CITY_BLOCK_MSG; + setMsg(tip); + toast(tip); + return; + } if (!quantityOk) { setMsg( isCross @@ -219,9 +267,12 @@ export default function OrderConfirmPage() { ? '提交中…' : !addressId ? '请选择地址' - : !quantityOk - ? `至少购买 ${minQty} 瓶` - : '提交订单'; + : !addressOk + ? '请更换地址' + : !quantityOk + ? `至少购买 ${minQty} 瓶` + : '提交订单'; + const displayMsg = msg || addressHint; return ( @@ -254,7 +305,13 @@ export default function OrderConfirmPage() { )} - {isCross ? ( + {!addressOk && addressId ? ( + + {addressHint || CROSS_CITY_BLOCK_MSG} + + ) : null} + + {isCross && addressOk ? ( 该地址超出同城配送范围,将由总部物流发货,运费到付 @@ -333,9 +390,9 @@ export default function OrderConfirmPage() { {!previewLoading && !preview && productId ? ( 无法加载商品信息 ) : null} - {msg ? ( - - {msg} + {displayMsg ? ( + + {displayMsg} ) : null} diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index c3248d2..0d58126 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -16,6 +16,11 @@ import { getProductMainImage, type ProductImageSource, } from '../../lib/product-images'; +import { + canBuyOnline, + canPickupOnSite, + normalizeFulfillmentFlags, +} from '../../lib/product-fulfillment'; import { DEFAULT_SHARE_DESC, DEFAULT_SHARE_TITLE, @@ -49,7 +54,7 @@ export default function ProductDetailPage() { useEffect(() => { if (!productId) return; request(`/catalog/products/${productId}`) - .then(setProduct) + .then((p) => setProduct(normalizeFulfillmentFlags(p))) .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); }, [productId]); @@ -107,9 +112,6 @@ export default function ProductDetailPage() { Taro.navigateTo({ url: returnPath }); } - const allowOnline = product?.allowOnlinePurchase !== false; - const allowOnSite = !!product?.allowOnSitePickup; - if (!product) { return ( @@ -119,6 +121,8 @@ export default function ProductDetailPage() { ); } + const allowOnline = canBuyOnline(product); + const allowOnSite = canPickupOnSite(product); const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price); const carouselImages = getProductCarouselImages(product); const detailImages = getProductDetailImages(product); diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 30ec234..f9c49e6 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -81,6 +81,19 @@ box-shadow: var(--shadow-card); } +.order-card--warn { + background: rgba(166, 29, 36, 0.06); + box-shadow: none; + border: 1px solid rgba(166, 29, 36, 0.15); +} + +.order-warn-text { + display: block; + font-size: 13px; + line-height: 1.5; + color: var(--color-heritage-red); +} + .order-card-title { display: block; font-family: var(--font-headline); diff --git a/server/dukang-api/src/modules/common/client-error.service.ts b/server/dukang-api/src/modules/common/client-error.service.ts index 15f4215..2e7a814 100644 --- a/server/dukang-api/src/modules/common/client-error.service.ts +++ b/server/dukang-api/src/modules/common/client-error.service.ts @@ -23,6 +23,14 @@ export class ClientErrorService { const stack = dto.stack?.trim().slice(0, 4000); const pagePath = dto.pagePath?.trim().slice(0, 128); const fingerprint = `${dto.level}|${dto.category}|${message.slice(0, 120)}`; + const anonymous = user?.actorId == null; + const skipWecom = shouldSkipWecomClientErrorAlert({ + message, + pagePath, + clientApp, + anonymous, + category: dto.category, + }); const logLine = { level: dto.level, @@ -34,6 +42,7 @@ export class ClientErrorService { actorId: user?.actorId != null ? String(user.actorId) : undefined, stack: stack?.slice(0, 800), extra: dto.extra, + skipWecom, }; if (dto.level === 'fatal') { @@ -61,6 +70,7 @@ export class ClientErrorService { actorType: user?.actorType ?? null, actorId: user?.actorId != null ? String(user.actorId) : null, storeId: user?.storeId != null ? String(user.storeId) : null, + skipWecom, ...(dto.extra && typeof dto.extra === 'object' ? { clientExtra: dto.extra } : {}), } as Prisma.InputJsonValue, }, @@ -71,7 +81,7 @@ export class ClientErrorService { ); } - if (WECOM_LEVELS.has(dto.level)) { + if (WECOM_LEVELS.has(dto.level) && !skipWecom) { const alertLevel: AlertLevel = dto.level === 'fatal' ? 'P0' : 'P1'; this.alert.notify({ level: alertLevel, @@ -91,12 +101,56 @@ export class ClientErrorService { dedupeKey: `client_error|${fingerprint}`, dedupeTtlSec: 600, }); + } else if (skipWecom && WECOM_LEVELS.has(dto.level)) { + this.logger.log( + `[client_error] skip WeCom alert (likely mini-program audit noise): ${message.slice(0, 160)}`, + ); } return { ok: true }; } } +/** + * 过滤企微推送:微信小程序审核机 / 自动化探测常见噪声。 + * 仍落库与写服务端日志,仅跳过 webhook。 + * + * 典型特征(与本次协议页报错一致): + * - 匿名 USER_MINI + * - navigateTo/redirectTo 等 page … is not found(常带 .html,Taro H5 路径形态) + */ +export function shouldSkipWecomClientErrorAlert(input: { + message: string; + pagePath?: string | null; + clientApp: string; + anonymous: boolean; + category?: string; +}): boolean { + const msg = input.message || ''; + const app = input.clientApp || ''; + const isMini = app === 'USER_MINI' || app === 'PARTNER_MINI' || app === 'HQ_MINI'; + + // 路由页不存在:审核机点协议/隐私链接触发最常见 + const isNavPageMissing = + /(navigateTo|redirectTo|reLaunch|switchTab):fail/i.test(msg) && + /is not found/i.test(msg); + + // Taro 把路径拼成 *.html 的形态,几乎不可能是真·原生页路径 + const isTaroHtmlPagePath = + /\.html(\b|"|')/i.test(msg) && /is not found|page /i.test(msg); + + if (isMini && input.anonymous && (isNavPageMissing || isTaroHtmlPagePath)) { + return true; + } + + // 即使带登录态,纯 *.html not found 也视为框架/探测噪声 + if (isMini && isTaroHtmlPagePath) { + return true; + } + + return false; +} + function isClientApp(v: string): v is ClientApp { return [ 'USER_MINI', diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index b10c9ae..0242f2d 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -86,18 +86,21 @@ export class TradeService { } } + let addressOk = true; + let addressMessage: string | null = null; if (!onSitePickup) { const allowOnline = product.allowOnlinePurchase !== false; const allowCross = product.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { - throw new BadRequestException('该商品不支持线上购买'); - } - if (deliveryType === 'CROSS_CITY') { + addressOk = false; + addressMessage = '该商品不支持线上购买'; + } else if (deliveryType === 'CROSS_CITY') { if (!allowOnline) { - throw new BadRequestException('该商品不支持线上购买'); - } - if (!allowCross) { - throw new BadRequestException('该商品不支持跨城配送'); + addressOk = false; + addressMessage = '该商品不支持线上购买'; + } else if (!allowCross) { + addressOk = false; + addressMessage = '该商品不支持跨城配送,请更换为开城城市内的收货地址'; } } } @@ -137,8 +140,13 @@ export class TradeService { /** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */ quantityOk: check.ok, quantityMessage: check.ok ? null : (check.message ?? null), + /** 地址/履约未满足时仍返回预览,供确认页提示换地址;下单接口仍会硬校验 */ + addressOk, + addressMessage, minQty, onSitePickup, + allowCrossCityDelivery: product.allowCrossCityDelivery !== false, + allowOnlinePurchase: product.allowOnlinePurchase !== false, }; } @@ -157,6 +165,9 @@ export class TradeService { if (preview.quantityOk === false) { throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求'); } + if (preview.addressOk === false) { + throw new BadRequestException(preview.addressMessage || '收货地址不可用'); + } const onSitePickup = !!body.onSitePickup || preview.deliveryType === 'ON_SITE_PICKUP'; let receiverName = '现场取货';