import { BadRequestException, Injectable } from '@nestjs/common'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { TestWhitelistService, normalizeTestPhone, } from '../../common/test-whitelist/test-whitelist.service'; import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper'; import { flattenSkuOntoProduct, listMinOnSalePrice, mapSkuDto, mapSpecAttrsDto, pickDisplaySku, resolveOrderSale, } from './product-sku.util'; export type CatalogViewer = { /** C 端用户手机号;无则无法看到白名单商品 */ phone?: string | null; /** 仅总部代下单等运营场景跳过白名单;合伙人端必须遵守白名单 */ bypassWhitelist?: boolean; }; @Injectable() export class CatalogService { constructor( private readonly prisma: PrismaService, private readonly testWhitelist: TestWhitelistService, ) {} private async whitelistPhoneSet(): Promise> { const rows = await this.prisma.commonTestWhitelistPhone.findMany({ select: { phone: true }, }); return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean)); } isVisibleToViewer( product: { visibilityWhitelistEnabled: boolean }, viewer?: CatalogViewer, whitelistPhones?: Set, ): boolean { if (viewer?.bypassWhitelist) return true; if (!product.visibilityWhitelistEnabled) return true; const phone = normalizeTestPhone(viewer?.phone); if (!phone) return false; if (whitelistPhones) return whitelistPhones.has(phone); return false; } async listCities() { const cities = await this.prisma.commonCity.findMany({ where: { status: 'ACTIVE' }, include: { partnerAccounts: { where: { isPrimary: 1, bindingStatus: 'ACTIVE' }, orderBy: { createdAt: 'asc' }, select: { id: true, companyName: true, scopeType: true }, }, }, orderBy: { name: 'asc' }, }); return serializeBigInt( cities.map((city) => ({ ...city, partnerBindingCount: city.partnerAccounts.length, partnerBindings: city.partnerAccounts.map((bp) => ({ partnerAccountId: bp.id.toString(), partnerId: bp.id.toString(), companyName: bp.companyName, scopeType: bp.scopeType, })), partnerAccounts: undefined, })), ); } async listProducts(aromaType?: string, cityCode?: string, viewer?: CatalogViewer) { if (cityCode) { const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode, status: 'ACTIVE' }, }); if (!city) throw new BadRequestException('该城市暂未开城'); } const products = await this.prisma.commonProductItem.findMany({ where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) }, orderBy: { sortOrder: 'asc' }, include: { coverResource: true, skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, specAttrs: { select: { id: true } }, }, }); const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled) ? await this.whitelistPhoneSet() : new Set(); const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones)); const productIds = visible.map((p) => p.id); const resources = productIds.length ? await this.prisma.commonResource.findMany({ where: { ownerType: 'PRODUCT', ownerId: { in: productIds }, status: 'ACTIVE', bizType: { in: ['CAROUSEL', 'DETAIL'] }, }, orderBy: { sortOrder: 'asc' }, }) : []; const resourceMap = groupResourcesByProductId(resources); return serializeBigInt( visible.map((p) => { const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []); const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = p; const display = pickDisplaySku(skus); const flat = flattenSkuOntoProduct(p, display); const minPrice = listMinOnSalePrice(skus); const price = minPrice ?? flat.price; const benefitAmount = flat.benefitAmount; return { ...rest, skuCode: flat.skuCode, barcode69: undefined, spec: flat.spec, price, benefitAmount, benefitDisplay: benefitAmount, allowOnSitePickup: flat.allowOnSitePickup, allowOnlinePurchase: flat.allowOnlinePurchase, allowCrossCityDelivery: flat.allowCrossCityDelivery, specEnabled: specAttrs.length > 0 || skus.length > 1, saleUnit: flat.saleUnit, ...media, }; }), ); } async getProduct(id: bigint, viewer?: CatalogViewer) { const product = await this.prisma.commonProductItem.findUnique({ where: { id }, include: { coverResource: true, skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], include: { skuSpecs: { select: { valueId: true } } }, }, specAttrs: { orderBy: { sortOrder: 'asc' }, include: { values: { orderBy: { sortOrder: 'asc' } } }, }, }, }); if (!product) return null; const whitelistPhones = product.visibilityWhitelistEnabled ? await this.whitelistPhoneSet() : new Set(); if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) { return null; } const resources = await this.prisma.commonResource.findMany({ where: { ownerType: 'PRODUCT', ownerId: id, status: 'ACTIVE', bizType: { in: ['CAROUSEL', 'DETAIL'] }, }, orderBy: { sortOrder: 'asc' }, }); const media = mapProductMedia(product, resources); const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = product; const display = pickDisplaySku(skus); const flat = flattenSkuOntoProduct(product, display); const defaultSku = skus.find((s) => s.isDefault) ?? display; const cSkus = skus.map((s) => { const dto = mapSkuDto(s); const { skuCode: _c, barcode69: _b, sortOrder: _o, ...publicSku } = dto; return publicSku; }); return serializeBigInt({ ...rest, skuCode: flat.skuCode, spec: flat.spec, price: flat.price, benefitAmount: flat.benefitAmount, benefitDisplay: flat.benefitAmount, allowOnSitePickup: flat.allowOnSitePickup, allowOnlinePurchase: flat.allowOnlinePurchase, allowCrossCityDelivery: flat.allowCrossCityDelivery, saleUnit: flat.saleUnit, specEnabled: specAttrs.length > 0 || skus.length > 1, specAttrs: mapSpecAttrsDto(specAttrs), skus: cSkus, defaultSkuId: defaultSku?.id.toString(), ...media, }); } /** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */ async assertPurchasable( productId: bigint, viewerPhone?: string | null, skuId?: string | null, options?: { bypassWhitelist?: boolean }, ) { const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId }, include: { skus: true }, }); if (!product || product.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) { const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone); if (!ok) { throw new BadRequestException('该商品暂不对当前账号开放'); } } const sale = resolveOrderSale(product, product.skus ?? [], skuId); return { product, sale }; } async resolveUserPhone(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { phone: true }, }); return user?.phone ?? null; } async listSkusForProduct(productId: bigint) { return this.prisma.commonProductSku.findMany({ where: { productId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], include: { skuSpecs: { select: { valueId: true } } }, }); } }