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
+17
View File
@@ -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)
@@ -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<string>();
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 })),
},
}
: {}),
},
});
@@ -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 {
@@ -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,