| 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 ? (
+ <>
+
+
+
+
+
+ >
+ ) : 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,