refactor(api): group public tag filters

This commit is contained in:
yeuimu
2026-08-21 11:34:57 +08:00
parent 4963a5c463
commit 8b38d6fef7
5 changed files with 116 additions and 29 deletions
@@ -0,0 +1,35 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
PublicQueryGoodDto,
PublicTagFilterDto,
} from './public-query-good.dto';
describe('PublicQueryGoodDto', () => {
it('parses tags from a JSON query parameter into nested DTOs', async () => {
const dto = plainToInstance(PublicQueryGoodDto, {
tags: JSON.stringify([
{ tagGroupId: '1', tagIds: ['11', '12'] },
{ tagGroupId: '2', tagIds: ['25'] },
]),
});
expect(dto.tags).toHaveLength(2);
expect(dto.tags?.[0]).toBeInstanceOf(PublicTagFilterDto);
expect(dto.tags?.[0]).toEqual({
tagGroupId: '1',
tagIds: ['11', '12'],
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('rejects malformed group and tag ids', async () => {
const dto = plainToInstance(PublicQueryGoodDto, {
tags: JSON.stringify([
{ tagGroupId: 'craft', tagIds: ['11', 'bad'] },
]),
});
expect(await validate(dto)).not.toHaveLength(0);
});
});
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { plainToInstance, Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
@@ -7,7 +7,8 @@ import {
IsNumberString,
IsOptional,
IsString,
Matches,
ValidateNested,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator';
@@ -21,6 +22,32 @@ const stringList = ({ value }: { value: unknown }): string[] | undefined => {
.filter(Boolean);
};
const tagFilters = ({ value }: { value: unknown }): unknown => {
if (value === undefined || value === null || value === '') return undefined;
const values = Array.isArray(value) ? value : [value];
try {
return values.flatMap((item) => {
if (typeof item !== 'string') return [item];
const parsed = JSON.parse(item) as unknown;
return Array.isArray(parsed) ? parsed : [parsed];
}).map((item) => plainToInstance(PublicTagFilterDto, item));
} catch {
return value;
}
};
export class PublicTagFilterDto {
@ApiProperty({ example: '1' })
@IsNumberString()
tagGroupId!: string;
@ApiProperty({ type: [String], example: ['11', '12', '13'] })
@IsArray()
@ArrayNotEmpty()
@IsNumberString({}, { each: true })
tagIds!: string[];
}
export class PublicQueryGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@@ -49,19 +76,19 @@ export class PublicQueryGoodDto {
@ApiProperty({
required: false,
type: [String],
type: [PublicTagFilterDto],
description:
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND',
example: ['1:11', '1:12', '2:25'],
'标签筛选分组。同组 tagIds 按 OR 匹配,不同数组元素按 AND 匹配。GET 请求建议将整个数组 JSON.stringify 后传入 tags',
example: [
{ tagGroupId: '1', tagIds: ['11', '12'] },
{ tagGroupId: '2', tagIds: ['25'] },
],
})
@IsOptional()
@Transform(stringList)
@Transform(tagFilters)
@IsArray()
@Matches(/^\d+:\d+$/, {
each: true,
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
})
tagIds?: string[];
@ValidateNested({ each: true })
tags?: PublicTagFilterDto[];
@ApiProperty({ required: false })
@IsOptional()
+22 -8
View File
@@ -247,9 +247,12 @@ describe('PublicService', () => {
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds
.slice(0, 2)
.map((tagId) => `${filterGroupIds[0]}:${tagId}`),
tags: [
{
tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
],
});
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
@@ -260,10 +263,16 @@ describe('PublicService', () => {
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds.map(
(tagId, index) =>
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`,
),
tags: [
{
tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[2].toString()],
},
],
});
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
@@ -274,7 +283,12 @@ describe('PublicService', () => {
service.getGoods({
page: 1,
pageSize: 20,
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`],
tags: [
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[0].toString()],
},
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
+12 -9
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto';
@@ -145,9 +146,7 @@ export class PublicService {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
}
const tagFilters = await this.buildTagGroupFilters([
...new Set(query.tagIds ?? []),
]);
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
@@ -319,16 +318,20 @@ export class PublicService {
}
private async buildTagGroupFilters(
selectedTagValues: string[],
selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedTagValues.map((value) => {
const [tagGroupId, tagId] = value.split(':');
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
const selections = selectedGroups.flatMap((group) => {
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
throw new BadRequestException(
'tagIds 每个元素必须 tagGroupId:tagId',
'tags 每个元素必须包含合法的 tagGroupIdtagIds',
);
}
return { tagGroupId, tagId };
return group.tagIds.map((tagId) => {
if (!/^\d+$/.test(tagId)) {
throw new BadRequestException('tagIds 必须全部为数字字符串');
}
return { tagGroupId: group.tagGroupId, tagId };
});
});
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
const selected = uniqueTagIds.length
@@ -316,7 +316,15 @@ export function useProductCenter() {
if (query.countryId) params.countryId = query.countryId;
if (query.categoryId) params.categoryId = query.categoryId;
if (!options.ignoreTags && query.tagIds.length > 0) {
params.tagIds = query.tagIds.join(',');
const grouped = new Map<string, string[]>();
for (const tagId of query.tagIds) {
const groupId = tags.value.find((tag) => tag.id === tagId)?.group?.id;
if (!groupId) continue;
grouped.set(groupId, [...(grouped.get(groupId) ?? []), tagId]);
}
params.tags = JSON.stringify(
[...grouped].map(([tagGroupId, tagIds]) => ({ tagGroupId, tagIds })),
);
}
if (query.keyword?.trim()) params.keyword = query.keyword.trim();