refactor(api): group public tag filters
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 每个元素必须包含合法的 tagGroupId 和 tagIds',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user