From ebd8c071475d1ce6191b169876a3c042113f6163 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 13:22:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=95=B0=E9=87=8F=E5=8A=A0=E5=87=8F?= =?UTF-8?q?=EF=BC=9A=E5=90=8C=E5=9F=8E/=E8=B7=A8=E5=9F=8E/=E7=8E=B0?= =?UTF-8?q?=E5=9C=BA=E5=8F=96=E8=B4=A7=E7=A1=AE=E8=AE=A4=E9=A1=B5=E5=8A=A0?= =?UTF-8?q?=E5=87=8F=E5=8F=B7=E4=B8=8D=E5=86=8D=E7=BD=AE=E7=81=B0=EF=BC=9B?= =?UTF-8?q?=E7=82=B9=E5=87=8F=E5=88=B0=E4=BD=8E=E4=BA=8E=E8=B5=B7=E8=B4=AD?= =?UTF-8?q?=E6=97=B6=20toast=EF=BC=8C=E6=95=B0=E9=87=8F=E4=BB=8D=E5=8F=AF?= =?UTF-8?q?=E9=99=8D=E5=88=B0=201=EF=BC=88=E4=B8=8D=E8=83=BD=E5=88=B0=200?= =?UTF-8?q?=EF=BC=89=E3=80=82=20=E6=94=AF=E4=BB=98=E5=90=8E=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=EF=BC=9A=E6=94=AF=E4=BB=98=E6=88=90=E5=8A=9F=E8=BF=9B?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=B8=A6=20from=3Dpay=EF=BC=9B=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E4=B8=80=E5=BE=8B=20switchTab=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=EF=BC=88=E5=A4=B1=E8=B4=A5=E5=88=99=20reLaunch=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=A0=88=E5=8F=AA=E6=9C=89=E4=B8=80?= =?UTF-8?q?=E9=A1=B5=E6=97=B6=20navigateBack=20=E9=80=80=E5=87=BA=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E3=80=82=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=90=8C=E6=A0=B7=E5=8A=A0=E5=9B=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/order-confirm-pickup/index.tsx | 15 ++++++++++----- apps/mini-user/src/pages/order-confirm/index.tsx | 6 +++--- apps/mini-user/src/pages/order-detail/index.tsx | 11 +++++++---- apps/mini-user/src/pages/orders/index.tsx | 6 +++++- apps/mini-user/src/pages/pay/index.tsx | 6 +++--- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx index 9d187e8..3166e60 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -75,11 +75,12 @@ export default function OrderConfirmPickupPage() { const canSubmit = !!preview && quantityOk && !loading && !previewLoading; function updateQuantity(next: number) { - if (next < 1) return; if (next < minQty) { const tip = `现场提货至少购买 ${minQty} 瓶`; toast(tip); setMsg(tip); + if (next < 1) return; + setQuantity(next); return; } setQuantity(next); @@ -101,8 +102,12 @@ export default function OrderConfirmPickupPage() { async function submit() { if (!canSubmit) { - if (!quantityOk) setMsg(`现场提货至少购买 ${minQty} 瓶`); - return; + if (!quantityOk) { + const tip = `现场提货至少购买 ${minQty} 瓶`; + setMsg(tip); + toast(tip); + return; + } } const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`; @@ -191,8 +196,8 @@ export default function OrderConfirmPickupPage() { 购买数量 updateQuantity(Math.max(minQty, quantity - 1))} + className="order-qty-btn" + onClick={() => updateQuantity(quantity - 1)} > diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 1195316..f16545b 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -167,14 +167,14 @@ export default function OrderConfirmPage() { : ''; function updateQuantity(next: number) { - if (next < 1) return; if (next < minQty) { const tip = isCross ? `跨城配送至少购买 ${minQty} 瓶(1箱)` : `同城配送至少购买 ${minQty} 瓶`; toast(tip); setMsg(tip); - setQuantity(Math.max(1, next)); + if (next < 1) return; + setQuantity(next); return; } setQuantity(next); @@ -352,7 +352,7 @@ export default function OrderConfirmPage() { 购买数量 updateQuantity(quantity - 1)} > diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 83cb554..a3f3530 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -181,12 +181,15 @@ export default function OrderDetailPage() { { - const pages = Taro.getCurrentPages(); - if (pages.length > 1) { - Taro.navigateBack(); + // 支付完成后 reLaunch 进详情:栈仅一页时 navigateBack 会退出小程序,统一回首页 + const fromPay = String(router.params.from || '') === 'pay'; + if (fromPay || Taro.getCurrentPages().length <= 1) { + Taro.switchTab({ url: '/pages/home/index' }).catch(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }); return; } - Taro.switchTab({ url: '/pages/home/index' }); + Taro.navigateBack(); }} right={} /> diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx index 0a87cf5..6e44413 100644 --- a/apps/mini-user/src/pages/orders/index.tsx +++ b/apps/mini-user/src/pages/orders/index.tsx @@ -95,7 +95,11 @@ export default function OrdersPage() { Taro.switchTab({ url: '/pages/home/index' })} + onBack={() => { + Taro.switchTab({ url: '/pages/home/index' }).catch(() => { + Taro.reLaunch({ url: '/pages/home/index' }); + }); + }} /> {TABS.map((t) => ( diff --git a/apps/mini-user/src/pages/pay/index.tsx b/apps/mini-user/src/pages/pay/index.tsx index 1755d67..113d70a 100644 --- a/apps/mini-user/src/pages/pay/index.tsx +++ b/apps/mini-user/src/pages/pay/index.tsx @@ -144,10 +144,10 @@ export default function PayPage() { toast('支付成功', 'success'); } if (deliveryType === 'ON_SITE_PICKUP') { - // 现场提货支付即完成 → 订单详情(已完成);reLaunch 清掉商品详情栈 - Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}` }); + // 现场提货支付即完成 → 订单详情;from=pay 返回强制回首页,避免 navigateBack 退出小程序 + Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}&from=pay` }); } else { - Taro.reLaunch({ url: '/pages/orders/index?tab=paid' }); + Taro.reLaunch({ url: '/pages/orders/index?tab=paid&from=pay' }); } } catch (e) { if (isWechatAuthRequiredError(e)) { From 06cbcdeb9c45275708619e294de6dca86e7510da Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 13:51:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=95=86=E5=93=81?= =?UTF-8?q?=EF=BC=9A=E5=AF=B9=E9=BD=90=E9=97=A8=E5=BA=97=E3=80=8C=E5=BD=93?= =?UTF-8?q?=E6=AC=A1=E4=BC=9A=E8=AF=9D=E3=80=8D=E2=80=94=E2=80=94=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E8=BF=9B=E5=85=A5=20/=20=E5=9F=8E=E5=B8=82=E5=8F=98?= =?UTF-8?q?=E5=8C=96=20/=20=E7=99=BB=E5=BD=95=E6=80=81=E5=8F=98=E5=8C=96?= =?UTF-8?q?=20/=20=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0=E6=89=8D=E6=8B=89?= =?UTF-8?q?=E5=88=97=E8=A1=A8=EF=BC=8C=E5=88=87=20Tab=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AF=B7=E6=B1=82=EF=BC=9B=E9=80=80=E5=87=BA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=BC=9A=E6=B8=85=E7=BC=93=E5=AD=98=E3=80=82?= =?UTF-8?q?=20=E6=A0=B8=E9=94=80=E6=88=90=E5=8A=9F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=9A=E6=8C=89=20Asia/Shanghai=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E4=B8=BA=E3=80=8C2026=E5=B9=B48=E6=9C=883=E6=97=A5=20?= =?UTF-8?q?13=E7=82=B945=E5=88=86=E3=80=8D=E3=80=82=20=E6=A0=B8=E9=94=80?= =?UTF-8?q?=E7=A0=81=E7=BC=96=E5=8F=B7=EF=BC=9A=E5=8D=95=E8=A1=8C=E7=9C=81?= =?UTF-8?q?=E7=95=A5=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=8F=8C=E5=87=BB=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=EF=BC=88=E6=9C=89=E3=80=8C=E5=8F=8C=E5=87=BB=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E3=80=8D=E6=8F=90=E7=A4=BA=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/src/lib/api.ts | 4 +- .../mini-user/src/lib/home-catalog-session.ts | 75 +++++++++++++++++ apps/mini-user/src/pages/home/index.tsx | 80 +++++++++++++------ .../mini-user/src/pages/redeem-code/index.tsx | 17 +++- .../src/pages/redeem-success/index.tsx | 27 ++++++- apps/mini-user/src/styles/redeem.css | 12 ++- 6 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 apps/mini-user/src/lib/home-catalog-session.ts diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts index 211d905..1f27fe9 100644 --- a/apps/mini-user/src/lib/api.ts +++ b/apps/mini-user/src/lib/api.ts @@ -2,6 +2,7 @@ import Taro from '@tarojs/taro'; import { ClientApp } from '@dukang/shared-types'; import { forceReloadAfterAccountMerge } from './auth-nav'; import { resetStoresSessionBootstrap } from './stores-session'; +import { resetHomeCatalogBootstrap } from './home-catalog-session'; function resolveApiBase(): string { const origin = @@ -57,8 +58,9 @@ export function isLoggedIn(): boolean { export function logout() { clearAuth(); - // 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选 + // 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选 resetStoresSessionBootstrap(); + resetHomeCatalogBootstrap(); Taro.reLaunch({ url: '/pages/home/index' }); } diff --git a/apps/mini-user/src/lib/home-catalog-session.ts b/apps/mini-user/src/lib/home-catalog-session.ts new file mode 100644 index 0000000..72eca7a --- /dev/null +++ b/apps/mini-user/src/lib/home-catalog-session.ts @@ -0,0 +1,75 @@ +/** + * 首页商品列表「当次登录」会话 —— 切 tab 不重复拉商品; + * 城市 / 登录态变化或下拉刷新时再请求。logout 时 clear。 + */ + +import Taro from '@tarojs/taro'; + +export type HomeCatalogCache = { + cityCode: string; + authKey: string; + products: unknown[]; +}; + +type HomeSession = { + bootstrapped: boolean; + cache: HomeCatalogCache | null; +}; + +const STORAGE_KEY = 'dukang_home_catalog_session_v1'; + +let memory: HomeSession | null = null; + +function emptySession(): HomeSession { + return { bootstrapped: false, cache: null }; +} + +function readSession(): HomeSession { + if (memory) return memory; + try { + const raw = Taro.getStorageSync(STORAGE_KEY); + if (!raw) { + memory = emptySession(); + return memory; + } + const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial; + memory = { + bootstrapped: !!parsed.bootstrapped, + cache: (parsed.cache as HomeCatalogCache | null) ?? null, + }; + return memory; + } catch { + memory = emptySession(); + return memory; + } +} + +function writeSession(next: HomeSession) { + memory = next; + try { + Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } +} + +export function isHomeCatalogBootstrapped(): boolean { + return readSession().bootstrapped; +} + +export function getHomeCatalogCache(): HomeCatalogCache | null { + return readSession().cache; +} + +export function setHomeCatalogCache(cache: HomeCatalogCache): void { + writeSession({ bootstrapped: true, cache }); +} + +export function resetHomeCatalogBootstrap(): void { + memory = emptySession(); + try { + Taro.removeStorageSync(STORAGE_KEY); + } catch { + /* ignore */ + } +} diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index a4ef76d..6657198 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components'; import Taro, { useDidShow, @@ -13,7 +13,12 @@ import CouponBadge from '../../components/CouponBadge'; import WechatShareReady from '../../components/WechatShareReady'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import { goLogin } from '../../lib/auth-nav'; -import { isLoggedIn, request, toast } from '../../lib/api'; +import { getToken, isLoggedIn, request, toast } from '../../lib/api'; +import { + getHomeCatalogCache, + isHomeCatalogBootstrapped, + setHomeCatalogCache, +} from '../../lib/home-catalog-session'; import { ensurePayReady } from '../../lib/pay-ready'; import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { getProductMainImage } from '../../lib/product-images'; @@ -29,7 +34,6 @@ import { DEFAULT_SHARE_TITLE, toWeappShareMessage, } from '../../lib/wechat-share'; - type Product = { id: string; name: string; @@ -110,35 +114,60 @@ export default function HomePage() { }); }, []); - const loadProducts = useCallback(() => { - setLoading(true); - return request(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) - .then((list) => - setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []), - ) - .catch((e) => toast(e instanceof Error ? e.message : '加载失败')) - .finally(() => setLoading(false)); - }, [cityCode]); + const applyProductList = useCallback((list: Product[], nextCode: string, authKey: string) => { + const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []; + setProducts(normalized); + setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized }); + }, []); + const fetchProducts = useCallback( + (nextCode: string, authKey: string) => { + setLoading(true); + return request(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`) + .then((list) => applyProductList(list, nextCode, authKey)) + .catch((e) => toast(e instanceof Error ? e.message : '加载失败')) + .finally(() => setLoading(false)); + }, + [applyProductList], + ); + + /** + * 首次进入 / 城市或登录态变化:拉商品。 + * 同次再切 tab:只同步选中态,不重复请求(对齐门店页)。 + */ useDidShow(() => { syncTabBarSelected(0); void capturePromoSceneAndTouchScan(); void loadMiniHome(); - // 白名单商品按登录手机号过滤;登录后 switchTab 回首页不会卸载页面,须重新拉列表 - void loadProducts(); - void resolveUserCity().then((resolved) => { - setDisplayCity(resolved.displayCity); - setCityCode(getCityCodeForCatalog(resolved)); - }); - }); - useEffect(() => { - void loadProducts(); - }, [loadProducts]); + const authKey = getToken() || ''; + void (async () => { + const resolved = await resolveUserCity(); + const nextCode = getCityCodeForCatalog(resolved); + setDisplayCity(resolved.displayCity); + setCityCode(nextCode); + + const cache = getHomeCatalogCache(); + if ( + isHomeCatalogBootstrapped() && + cache && + cache.cityCode === nextCode && + cache.authKey === authKey && + Array.isArray(cache.products) + ) { + setProducts(cache.products as Product[]); + setLoading(false); + return; + } + + await fetchProducts(nextCode, authKey); + })(); + }); usePullDownRefresh(() => { void (async () => { try { + const authKey = getToken() || ''; const resolved = await resolveUserCity(); setDisplayCity(resolved.displayCity); const nextCode = getCityCodeForCatalog(resolved); @@ -148,7 +177,11 @@ export default function HomePage() { request(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`), loadMiniHome(), ]); - setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []); + applyProductList( + Array.isArray(list) ? list : [], + nextCode, + authKey, + ); } catch (e) { toast(e instanceof Error ? e.message : '加载失败'); } finally { @@ -157,7 +190,6 @@ export default function HomePage() { } })(); }); - function openProductDetail(id: string) { Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); } diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx index 741851b..e7cde41 100644 --- a/apps/mini-user/src/pages/redeem-code/index.tsx +++ b/apps/mini-user/src/pages/redeem-code/index.tsx @@ -44,6 +44,20 @@ export default function RedeemCodePage() { const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const timerRef = useRef | null>(null); const successHandled = useRef(false); + const lastTokenTapAt = useRef(0); + + function onTokenTap() { + if (!token) return; + const now = Date.now(); + if (now - lastTokenTapAt.current < 350) { + lastTokenTapAt.current = 0; + void Taro.setClipboardData({ data: token }) + .then(() => toast('核销码编号已复制', 'success')) + .catch(() => toast('复制失败')); + return; + } + lastTokenTapAt.current = now; + } const stopTimer = useCallback(() => { if (timerRef.current != null) { @@ -144,9 +158,10 @@ export default function RedeemCodePage() { 待核销金额 ¥ {formatMoney(amount)} {token ? ( - + 核销码编号(供追查) {token} + 双击复制 ) : null} diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx index d7b07f9..0a336f4 100644 --- a/apps/mini-user/src/pages/redeem-success/index.tsx +++ b/apps/mini-user/src/pages/redeem-success/index.tsx @@ -20,6 +20,29 @@ function formatMoney(amount: number) { return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +/** 中国时区展示:2026年8月3日 13点45分 */ +function formatChinaDateTime(input?: string | null) { + const d = input ? new Date(input) : new Date(); + if (Number.isNaN(d.getTime())) return '—'; + const parts = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: false, + }).formatToParts(d); + const get = (type: Intl.DateTimeFormatPartTypes) => + parts.find((p) => p.type === type)?.value ?? ''; + const year = get('year'); + const month = String(Number(get('month'))); + const day = String(Number(get('day'))); + const hour = String(Number(get('hour'))); + const minute = get('minute').padStart(2, '0'); + return `${year}年${month}月${day}日 ${hour}点${minute}分`; +} + function StarRating({ label, value, @@ -65,9 +88,7 @@ export default function RedeemSuccessPage() { const amount = Number(record?.amount ?? router.params.amount ?? 0); const storeName = record?.storeName || '门店'; const redeemNo = record?.redeemNo || '—'; - const redeemedAt = record?.createdAt - ? new Date(record.createdAt).toLocaleString('zh-CN') - : new Date().toLocaleString('zh-CN'); + const redeemedAt = formatChinaDateTime(record?.createdAt); function clearCache() { try { diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css index a6d1891..45342a9 100644 --- a/apps/mini-user/src/styles/redeem.css +++ b/apps/mini-user/src/styles/redeem.css @@ -304,9 +304,19 @@ display: block; font-family: ui-monospace, monospace; font-size: 11px; - word-break: break-all; color: var(--color-on-surface); line-height: 1.5; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.redeem-code-token-hint { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--color-subtle-gray); } .redeem-success-icon { From fa3484041e817139f685227ebbee21e7b46e1e68 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 13:58:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(store):=20=E9=97=A8=E5=BA=97=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E7=99=BD=E5=90=8D=E5=8D=95=EF=BC=88=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=95=86=E5=93=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HQ 可配置 visibilityWhitelistEnabled + 手机号;C 端公开门店列表/详情按登录手机号过滤;登录态变化后小程序重新拉门店列表。 Co-authored-by: Cursor --- apps/admin-web/src/lib/storeCreate.ts | 2 + apps/admin-web/src/pages/StoresPage.tsx | 112 +++++++++++++++++- apps/mini-user/src/lib/stores-session.ts | 2 + .../src/pages/store-detail/index.tsx | 17 +-- apps/mini-user/src/pages/stores/index.tsx | 31 ++++- server/dukang-api/prisma/schema.prisma | 17 +++ .../src/modules/ops/admin-stores.service.ts | 86 +++++++++++++- .../src/modules/ops/dto/admin-mutate.dto.ts | 22 ++++ .../src/modules/store/store.controller.ts | 19 ++- .../src/modules/store/store.service.ts | 68 +++++++++-- 10 files changed, 344 insertions(+), 32 deletions(-) diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index c7a363e..37fa198 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -27,6 +27,8 @@ export type StoreCreateForm = { bankAccountNo: string; bankBranch: string; settlementRate?: number; + visibilityWhitelistEnabled?: boolean; + visibilityPhones?: string[]; }; const PHONE_RE = /^1\d{10}$/; diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 36f385a..1785d61 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, @@ -13,6 +13,7 @@ import { Select, Space, Steps, + Switch, Table, Tabs, Tag, @@ -20,6 +21,7 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import type { FormInstance } from 'antd/es/form'; import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons'; import { request, type Paginated } from '../lib/api'; import { @@ -274,6 +276,89 @@ function StoreAuditMediaSection({ detail }: { detail: Record }) ); } +type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null }; + +function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) { + const [userOptions, setUserOptions] = useState([]); + const [userSearching, setUserSearching] = useState(false); + const searchTimer = useRef | null>(null); + + async function searchUsers(keyword: string) { + const q = keyword.trim(); + if (searchTimer.current) clearTimeout(searchTimer.current); + if (!q) { + setUserOptions([]); + return; + } + searchTimer.current = setTimeout(() => { + void (async () => { + setUserSearching(true); + try { + const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q }); + const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`); + setUserOptions((res.items ?? []).filter((u) => !!u.phone)); + } catch { + setUserOptions([]); + } finally { + setUserSearching(false); + } + })(); + }, 300); + } + + const enabled = Form.useWatch('visibilityWhitelistEnabled', form); + + return ( + <> + + + + {enabled ? ( + <> + + ({ + value: u.phone!, + label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`, + }))} + onSearch={searchUsers} + onSelect={(phone: string) => { + const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? []; + if (!cur.includes(phone)) { + form.setFieldsValue({ visibilityPhones: [...cur, phone] }); + } + }} + notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'} + /> + + + ) : null} + + ); +} + type StoreRow = { id: string; name: string; @@ -287,6 +372,8 @@ type StoreRow = { intro: string | null; coverUrl: string | null; createdAt: string; + visibilityWhitelistEnabled?: boolean; + visibilityPhones?: string[]; cityRef?: { name: string; code: string }; partner?: { companyName: string }; account?: { @@ -504,6 +591,10 @@ export default function StoresPage() { bankAccountName: account?.bankAccountName || undefined, bankAccountNo: account?.bankAccountNo || undefined, bankBranch: account?.bankBranch || undefined, + visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled, + visibilityPhones: Array.isArray(d.visibilityPhones) + ? (d.visibilityPhones as string[]) + : [], }); setDrawerOpen(true); } @@ -543,6 +634,10 @@ export default function StoresPage() { bankAccountName: v.bankAccountName ?? null, bankAccountNo: v.bankAccountNo ?? null, bankBranch: v.bankBranch ?? null, + visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, + visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) + .map((p) => String(p || '').replace(/\D/g, '').trim()) + .filter(Boolean), ...(hasCoords ? { latitude: Number(v.latitude), longitude: Number(v.longitude) } : {}), @@ -632,6 +727,8 @@ export default function StoresPage() { openTime2: undefined, closeTime2: undefined, avgPrice: undefined, + visibilityWhitelistEnabled: false, + visibilityPhones: [], }); setCreateStep(0); setCreateError(''); @@ -713,6 +810,10 @@ export default function StoresPage() { bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), bankBranch: values.bankBranch.trim(), settlementRate: Number(values.settlementRate ?? 60) / 100, + visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, + visibilityPhones: (values.visibilityPhones ?? []) + .map((p: string) => String(p || '').replace(/\D/g, '').trim()) + .filter(Boolean), }), }); message.success('门店已创建'); @@ -766,6 +867,13 @@ export default function StoresPage() { }, }, { title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 }, + { + title: '可见', + dataIndex: 'visibilityWhitelistEnabled', + width: 90, + render: (v, row) => + v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开, + }, { title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, @@ -1059,6 +1167,7 @@ export default function StoresPage() { + ), }, @@ -1279,6 +1388,7 @@ export default function StoresPage() { > +
diff --git a/apps/mini-user/src/lib/stores-session.ts b/apps/mini-user/src/lib/stores-session.ts index fb4c13c..ae62305 100644 --- a/apps/mini-user/src/lib/stores-session.ts +++ b/apps/mini-user/src/lib/stores-session.ts @@ -22,6 +22,8 @@ export type StoresSessionCategory = { export type StoresListCache = { cityKey: string; cityCode: string; + /** 登录态指纹:token 变化时需重新拉取(白名单) */ + authKey: string; listRegion: StoresSessionRegion; items: unknown[]; filterRegion: StoresSessionRegion; diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index 35ca786..e4cc089 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { View, Text, Image } from '@tarojs/components'; -import Taro, { usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; +import Taro, { useDidShow, usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import PageNavBar from '../../components/PageNavBar'; import ProductCarousel from '../../components/ProductCarousel'; @@ -78,20 +78,15 @@ export default function StoreDetailPage() { setHeaderSolid(scrollTop > 100); }); - useEffect(() => { + useDidShow(() => { if (!storeId) return; request(`/stores/${storeId}`) .then(setStore) .catch(() => { - request('/stores') - .then((list) => { - const found = (Array.isArray(list) ? list : []).find((s) => s.id === storeId); - if (found) setStore(found); - else toast('门店不存在'); - }) - .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); + setStore(null); + toast('门店不存在或暂不可见'); }); - }, [storeId]); + }); const sharePayload = useMemo( () => { diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 20cd50a..dea186a 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -27,7 +27,7 @@ import { } from '../../lib/user-location'; import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { formatDistanceMeters } from '../../lib/geo'; -import { request, toast } from '../../lib/api'; +import { getToken, request, toast } from '../../lib/api'; import { getStoresListCache, isStoresSessionBootstrapped, @@ -143,6 +143,7 @@ export default function StoresPage() { setStoresListCache({ cityKey, cityCode: nextCode, + authKey: getToken() || '', listRegion: toCityWideRegion(listRegion), items, filterRegion, @@ -160,12 +161,38 @@ export default function StoresPage() { /** * 首次进入:弹窗 + 定位 + 拉列表。 - * 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState。 + * 同次再切回:只同步 tab 选中态(登录态未变)。 + * 登录/退出后 token 变化:按缓存失效重新拉列表(白名单)。 */ useDidShow(() => { syncTabBarSelected(1); + const authKey = getToken() || ''; + const cache = getStoresListCache(); if (isStoresSessionBootstrapped()) { + if (cache && (cache.authKey ?? '') === authKey) { + return; + } + // 登录态变了:保留筛选,重新拉列表 + void (async () => { + setLoading(true); + const nextCode = cache?.cityCode || FALLBACK_CITY_CODE; + const listRegion = cache?.listRegion + ? { + province: cache.listRegion.province, + city: cache.listRegion.city, + district: cache.listRegion.district || '全部', + } + : regionRef.current; + const nextCityKey = cache?.cityKey || makeCityKey(listRegion); + await fetchStores( + nextCode, + readCachedUserCoords(), + nextCityKey, + listRegion, + regionRef.current, + ); + })(); return; } markStoresSessionBootstrapped(); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 78e53ec..8adc5ef 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -1062,6 +1062,8 @@ model Store { openTime2 String? @map("open_time_2") @db.VarChar(8) closeTime2 String? @map("close_time_2") @db.VarChar(8) settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4) + /// Online test: only listed phones can see store on C-end when enabled + visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -1075,6 +1077,7 @@ model Store { ratings StoreRating[] payouts StorePayout[] storeBills StoreBill[] + visibilityPhones StoreVisibilityPhone[] @@index([cityId, status]) @@index([partnerAccountId]) @@ -1082,6 +1085,20 @@ model Store { @@map("store_store") } +/// Store visibility whitelist phones (match by bound user phone) +model StoreVisibilityPhone { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + phone String @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([storeId, phone]) + @@index([phone]) + @@map("store_visibility_phone") +} + model StoreAccount { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt phone String @unique @db.VarChar(20) diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 417fe68..ea5b1a6 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -25,6 +25,24 @@ function normalizeStoreOptionalText(value: unknown): string | null { return s; } +function normalizeVisibilityPhones(phones?: string[]): string[] { + if (!phones?.length) return []; + const out: string[] = []; + const seen = new Set(); + for (const raw of phones) { + const phone = String(raw || '') + .replace(/\D/g, '') + .trim(); + if (!phone || seen.has(phone)) continue; + if (!/^1\d{10}$/.test(phone)) { + throw new BadRequestException(`手机号格式无效:${raw}`); + } + seen.add(phone); + out.push(phone); + } + return out; +} + @Injectable() export class AdminStoresService { constructor( @@ -64,19 +82,23 @@ export class AdminStoresService { }, }, coverResource: { select: { id: true, url: true } }, + visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } }, }, }), this.prisma.store.count({ where }), ]); return serializeBigInt({ - items: items.map((s) => - mapStoreCompat({ - ...s, + items: items.map((s) => { + const { visibilityPhones, ...rest } = s; + return mapStoreCompat({ + ...rest, + visibilityWhitelistEnabled: s.visibilityWhitelistEnabled, + visibilityPhones: visibilityPhones.map((p) => p.phone), partner: s.partnerAccount, account: s.bindings[0]?.storeAccount ?? null, bindings: undefined, - }), - ), + }); + }), total, page, pageSize, @@ -96,6 +118,7 @@ export class AdminStoresService { include: { storeAccount: true }, }, coverResource: true, + visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } }, _count: { select: { redeemRecords: true, ratings: true } }, }, }); @@ -111,8 +134,12 @@ export class AdminStoresService { take: 5, }), ]); + const { visibilityPhones, ...rest } = store; return serializeBigInt(mapStoreCompat({ - ...store, + ...rest, + visibilityWhitelistEnabled: store.visibilityWhitelistEnabled, + visibilityPhones: visibilityPhones.map((p) => p.phone), + partner: store.partnerAccount, account: store.bindings[0]?.storeAccount ?? null, /** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */ loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone, @@ -235,6 +262,26 @@ export class AdminStoresService { } const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined; + if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) { + const nextEnabled = + dto.visibilityWhitelistEnabled !== undefined + ? !!dto.visibilityWhitelistEnabled + : current.visibilityWhitelistEnabled; + if (nextEnabled) { + const phones = + dto.visibilityPhones !== undefined + ? normalizeVisibilityPhones(dto.visibilityPhones) + : ( + await this.prisma.storeVisibilityPhone.findMany({ + where: { storeId: id }, + select: { phone: true }, + }) + ).map((p) => p.phone); + if (!phones.length) { + throw new BadRequestException('开启白名单时请至少添加一个手机号'); + } + } + } const bankTouched = dto.bankAccountName !== undefined || dto.bankAccountNo !== undefined || @@ -307,10 +354,23 @@ export class AdminStoresService { ...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}), ...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}), ...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}), + ...(dto.visibilityWhitelistEnabled !== undefined + ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } + : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}), }, }); + if (dto.visibilityPhones !== undefined) { + const phones = normalizeVisibilityPhones(dto.visibilityPhones); + await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } }); + if (phones.length) { + await tx.storeVisibilityPhone.createMany({ + data: phones.map((phone) => ({ storeId: id, phone })), + }); + } + } + if (dto.coverUrl) { if (current.coverResourceId) { await tx.commonResource.update({ @@ -430,6 +490,12 @@ export class AdminStoresService { throw new BadRequestException('好客权益券使用规则最多 1000 字'); } + const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones); + const whitelistEnabled = !!dto.visibilityWhitelistEnabled; + if (whitelistEnabled && !visibilityPhones.length) { + throw new BadRequestException('开启白名单时请至少添加一个手机号'); + } + const store = await this.prisma.store.create({ data: { cityId: city.id, @@ -449,11 +515,19 @@ export class AdminStoresService { closeTime, openTime2: openTime2 || null, closeTime2: closeTime2 || null, + visibilityWhitelistEnabled: whitelistEnabled, ...(latitude != null && longitude != null ? { latitude, longitude } : {}), status: 'OPEN', auditStatus: 'APPROVED', auditedAt: new Date(), rejectReason: null, + ...(visibilityPhones.length + ? { + visibilityPhones: { + create: visibilityPhones.map((phone) => ({ phone })), + }, + } + : {}), }, }); 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 4ed54c2..6adcf75 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 @@ -127,6 +127,17 @@ export class CreateStoreDto { @IsNumber() @Min(0) avgPrice?: number; + + /** 开启后仅白名单手机号在 C 端可见 */ + @IsOptional() + @IsBoolean() + visibilityWhitelistEnabled?: boolean; + + /** 可见白名单手机号列表 */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + visibilityPhones?: string[]; } export class UpdateStoreDto { @@ -215,6 +226,17 @@ export class UpdateStoreDto { @IsOptional() @IsString() bankBranch?: string | null; + + /** 开启后仅白名单手机号在 C 端可见 */ + @IsOptional() + @IsBoolean() + visibilityWhitelistEnabled?: boolean; + + /** 可见白名单手机号列表 */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + visibilityPhones?: string[]; } export class CreateStoreAccountDto { diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index dfc0d0e..4e26add 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -3,6 +3,7 @@ import { StoreService } from './store.service'; import { StoreCategoryService } from './store-category.service'; import { RedeemService } from '../redeem/redeem.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; +import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator'; import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard'; @@ -14,19 +15,29 @@ export class PublicStoreController { constructor(private readonly storeService: StoreService) {} @Get() - list( + @UseGuards(OptionalJwtAuthGuard) + async list( + @CurrentUser() user: AuthUser | undefined, @Query('cityCode') cityCode?: string, @Query('lat') lat?: string, @Query('lng') lng?: string, ) { const userLat = lat != null && lat !== '' ? Number(lat) : undefined; const userLng = lng != null && lng !== '' ? Number(lng) : undefined; - return this.storeService.listOpenStores(cityCode, userLat, userLng); + const viewerPhone = await this.resolveViewerPhone(user); + return this.storeService.listOpenStores(cityCode, userLat, userLng, { phone: viewerPhone }); } @Get(':id') - detail(@Param('id') id: string) { - return this.storeService.getStore(BigInt(id)); + @UseGuards(OptionalJwtAuthGuard) + async detail(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) { + const viewerPhone = await this.resolveViewerPhone(user); + return this.storeService.getStore(BigInt(id), { phone: viewerPhone }); + } + + private async resolveViewerPhone(user?: AuthUser) { + if (!user || user.actorType !== 'USER') return null; + return this.storeService.resolveUserPhone(user.actorId); } } diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index cb7ae83..2046c8f 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -36,6 +36,17 @@ function normalizeOptionalTextField(value: unknown): string | null { return s; } +export type StoreViewer = { + /** C 端用户手机号;无则无法看到白名单门店 */ + phone?: string | null; + /** 运营/代下单等场景跳过白名单 */ + bypassWhitelist?: boolean; +}; + +function normalizePhone(phone: string | null | undefined): string { + return (phone || '').replace(/\D/g, '').trim(); +} + function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null { if (value == null || value === '') return null; const n = typeof value === 'number' ? value : Number(value); @@ -96,7 +107,34 @@ export class StoreService { return { latitude: geo.latitude, longitude: geo.longitude }; } - async listOpenStores(cityCode?: string, userLat?: number, userLng?: number) { + async resolveUserPhone(userId: bigint): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { phone: true }, + }); + return user?.phone ?? null; + } + + isVisibleToViewer( + store: { + visibilityWhitelistEnabled: boolean; + visibilityPhones: Array<{ phone: string }>; + }, + viewer?: StoreViewer, + ): boolean { + if (viewer?.bypassWhitelist) return true; + if (!store.visibilityWhitelistEnabled) return true; + const phone = normalizePhone(viewer?.phone); + if (!phone) return false; + return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone); + } + + async listOpenStores( + cityCode?: string, + userLat?: number, + userLng?: number, + viewer?: StoreViewer, + ) { const where: Record = { status: 'OPEN' }; if (cityCode) { const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } }); @@ -104,10 +142,16 @@ export class StoreService { } const stores = await this.prisma.store.findMany({ where: where as never, - include: { category: true, coverResource: true }, + include: { + category: true, + coverResource: true, + visibilityPhones: { select: { phone: true } }, + }, orderBy: { createdAt: 'desc' }, }); + const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer)); + const hasUser = userLat != null && userLng != null && @@ -121,10 +165,11 @@ export class StoreService { }; const items: StoreListItem[] = []; - for (const store of stores) { + for (const store of visible) { const coords = await this.ensureStoreCoordinates(store); + const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; const mapped = mapStoreCompat({ - ...store, + ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, }); @@ -146,20 +191,27 @@ export class StoreService { return serializeBigInt(items); } - async getStore(id: bigint) { + async getStore(id: bigint, viewer?: StoreViewer) { const store = await this.prisma.store.findFirst({ where: { id, status: 'OPEN' }, - include: { category: true, coverResource: true }, + include: { + category: true, + coverResource: true, + visibilityPhones: { select: { phone: true } }, + }, }); - if (!store) throw new NotFoundException('门店不存在'); + if (!store || !this.isVisibleToViewer(store, viewer)) { + throw new NotFoundException('门店不存在'); + } const coords = await this.ensureStoreCoordinates(store); const media = await this.prisma.commonResource.findMany({ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' }, orderBy: { sortOrder: 'asc' }, }); + const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; return serializeBigInt( mapStoreCompat({ - ...store, + ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, media,