feat(store): 门店可见白名单(对齐商品)

HQ 可配置 visibilityWhitelistEnabled + 手机号;C 端公开门店列表/详情按登录手机号过滤;登录态变化后小程序重新拉门店列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 13:58:35 +08:00
parent 06cbcdeb9c
commit fa3484041e
10 changed files with 344 additions and 32 deletions
@@ -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);
}
}
@@ -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<string | null> {
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<string, unknown> = { 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,