Files
dukang/server/dukang-api/src/modules/catalog/catalog.service.ts
T
jacy 62e61a9e63
CI / verify (pull_request) Has been cancelled
v3.5.4版本提交,修改api
2026-08-21 16:22:53 +08:00

247 lines
8.1 KiB
TypeScript

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<Set<string>> {
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<string>,
): 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<string>();
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<string>();
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<string | null> {
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 } } },
});
}
}