From 8b38d6fef7315cf215bf71fd822def03a5ed6fe5 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 21 Aug 2026 11:34:57 +0800 Subject: [PATCH] refactor(api): group public tag filters --- .../public/dto/public-query-good.dto.spec.ts | 35 +++++++++++++ .../src/public/dto/public-query-good.dto.ts | 49 ++++++++++++++----- apps/api/src/public/public.service.spec.ts | 30 +++++++++--- apps/api/src/public/public.service.ts | 21 ++++---- .../app/composables/useProductCenter.ts | 10 +++- 5 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/public/dto/public-query-good.dto.spec.ts diff --git a/apps/api/src/public/dto/public-query-good.dto.spec.ts b/apps/api/src/public/dto/public-query-good.dto.spec.ts new file mode 100644 index 0000000..c7e5628 --- /dev/null +++ b/apps/api/src/public/dto/public-query-good.dto.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/public/dto/public-query-good.dto.ts b/apps/api/src/public/dto/public-query-good.dto.ts index 747e6bf..7eb8ef6 100644 --- a/apps/api/src/public/dto/public-query-good.dto.ts +++ b/apps/api/src/public/dto/public-query-good.dto.ts @@ -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() diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 125ea37..e5a10a6 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -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); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index e69c2ba..25c6357 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -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 { - 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 每个元素必须包含合法的 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 selected = uniqueTagIds.length diff --git a/apps/website/app/composables/useProductCenter.ts b/apps/website/app/composables/useProductCenter.ts index 075e907..a5f7f2f 100644 --- a/apps/website/app/composables/useProductCenter.ts +++ b/apps/website/app/composables/useProductCenter.ts @@ -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(); + 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();