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 { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { plainToInstance, Transform, Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsIn, IsIn,
@@ -7,7 +7,8 @@ import {
IsNumberString, IsNumberString,
IsOptional, IsOptional,
IsString, IsString,
Matches, ValidateNested,
ArrayNotEmpty,
Max, Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
@@ -21,6 +22,32 @@ const stringList = ({ value }: { value: unknown }): string[] | undefined => {
.filter(Boolean); .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 { export class PublicQueryGoodDto {
@ApiProperty({ required: false, default: 1 }) @ApiProperty({ required: false, default: 1 })
@IsOptional() @IsOptional()
@@ -49,19 +76,19 @@ export class PublicQueryGoodDto {
@ApiProperty({ @ApiProperty({
required: false, required: false,
type: [String], type: [PublicTagFilterDto],
description: description:
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND', '标签筛选分组。同组 tagIds 按 OR 匹配,不同数组元素按 AND 匹配。GET 请求建议将整个数组 JSON.stringify 后传入 tags',
example: ['1:11', '1:12', '2:25'], example: [
{ tagGroupId: '1', tagIds: ['11', '12'] },
{ tagGroupId: '2', tagIds: ['25'] },
],
}) })
@IsOptional() @IsOptional()
@Transform(stringList) @Transform(tagFilters)
@IsArray() @IsArray()
@Matches(/^\d+:\d+$/, { @ValidateNested({ each: true })
each: true, tags?: PublicTagFilterDto[];
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
})
tagIds?: string[];
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
+22 -8
View File
@@ -247,9 +247,12 @@ describe('PublicService', () => {
pageSize: 50, pageSize: 50,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
tagIds: filterTagIds tags: [
.slice(0, 2) {
.map((tagId) => `${filterGroupIds[0]}:${tagId}`), tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
],
}); });
expect(sameGroup.items.map((item) => item.goodName)).toEqual( expect(sameGroup.items.map((item) => item.goodName)).toEqual(
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
@@ -260,10 +263,16 @@ describe('PublicService', () => {
pageSize: 50, pageSize: 50,
countryId: countryId.toString(), countryId: countryId.toString(),
keyword: `Pub `, keyword: `Pub `,
tagIds: filterTagIds.map( tags: [
(tagId, index) => {
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`, 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)).toContain(`Pub High ${stamp}`);
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
@@ -274,7 +283,12 @@ describe('PublicService', () => {
service.getGoods({ service.getGoods({
page: 1, page: 1,
pageSize: 20, pageSize: 20,
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`], tags: [
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[0].toString()],
},
],
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
+12 -9
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { import {
PublicHomeGoodsQueryDto, PublicHomeGoodsQueryDto,
PublicQueryGoodDto, PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto'; } from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto'; import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto'; import { PublicCountryDto } from './dto/public-country.dto';
@@ -145,9 +146,7 @@ export class PublicService {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
} }
const tagFilters = await this.buildTagGroupFilters([ const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
...new Set(query.tagIds ?? []),
]);
if (tagFilters.length) where.AND = tagFilters; if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice'); const minPrice = this.parsePrice(query.minPrice, 'minPrice');
@@ -319,16 +318,20 @@ export class PublicService {
} }
private async buildTagGroupFilters( private async buildTagGroupFilters(
selectedTagValues: string[], selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> { ): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedTagValues.map((value) => { const selections = selectedGroups.flatMap((group) => {
const [tagGroupId, tagId] = value.split(':'); if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
throw new BadRequestException( 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 uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
const selected = uniqueTagIds.length const selected = uniqueTagIds.length
@@ -316,7 +316,15 @@ export function useProductCenter() {
if (query.countryId) params.countryId = query.countryId; if (query.countryId) params.countryId = query.countryId;
if (query.categoryId) params.categoryId = query.categoryId; if (query.categoryId) params.categoryId = query.categoryId;
if (!options.ignoreTags && query.tagIds.length > 0) { 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(); if (query.keyword?.trim()) params.keyword = query.keyword.trim();