feat(product-family): link-level tags (origin_good_tags) with manual override + member detail enrichment

This commit is contained in:
yeuimu
2026-08-28 16:00:46 +08:00
parent 37b52c5ebb
commit 08201f18a4
11 changed files with 477 additions and 87 deletions
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsNumber,
} from 'class-validator';
export class UpdateOriginGoodTagsDto {
@ApiProperty({ description: '链接标签 id 全量集合(人工接管)', type: [Number] })
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(50)
@IsNumber({}, { each: true })
@Type(() => Number)
tagIds: number[];
}
@@ -1,8 +1,9 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OriginGoodsService } from './origin-goods.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
import { UpdateOriginGoodTagsDto } from './dto/update-origin-good-tags.dto';
@ApiTags('origin-goods')
@ApiBearerAuth()
@@ -24,4 +25,22 @@ export class OriginGoodsController {
findAll(@Query() query: QueryOriginGoodDto) {
return this.service.findAll(query);
}
@Get(':id/tags')
@ApiOperation({ summary: '链接当前标签(含 manual 标记)' })
getTags(@Param('id') id: string) {
return this.service.getTags(BigInt(id));
}
@Put(':id/tags')
@ApiOperation({ summary: '人工接管链接标签(全量替换,自动同步不再覆盖)' })
updateTags(@Param('id') id: string, @Body() dto: UpdateOriginGoodTagsDto) {
return this.service.updateTags(BigInt(id), dto.tagIds);
}
@Delete(':id/tags')
@ApiOperation({ summary: '恢复自动派生(清掉人工标签)' })
resetTags(@Param('id') id: string) {
return this.service.resetTags(BigInt(id));
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { OriginGoodsController } from './origin-goods.controller';
import { OriginGoodsService } from './origin-goods.service';
import { ProductFamiliesModule } from '../product-families/product-families.module';
@Module({
imports: [ProductFamiliesModule],
controllers: [OriginGoodsController],
providers: [OriginGoodsService],
})
@@ -1,5 +1,6 @@
import { Test } from '@nestjs/testing';
import { OriginGoodsService } from './origin-goods.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => {
@@ -10,7 +11,7 @@ describe('OriginGoodsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [OriginGoodsService, PrismaService],
providers: [OriginGoodsService, FamilyRecomputeService, PrismaService],
}).compile();
service = moduleRef.get(OriginGoodsService);
prisma = moduleRef.get(PrismaService);
@@ -1,8 +1,27 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
export interface OriginGoodTagItem {
id: string;
tagName: string;
tagColor: string | null;
tagFontColor: string | null;
manual: boolean;
}
export interface OriginGoodTagsResult {
tagsManual: boolean;
tags: OriginGoodTagItem[];
}
export interface PaginatedOriginGoods {
items: Array<{
id: string;
@@ -67,7 +86,94 @@ export interface OriginGoodsTreeResponse {
@Injectable()
export class OriginGoodsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly familyRecompute: FamilyRecomputeService,
) {}
/** 链接当前标签(含 manual 标记) */
async getTags(id: bigint): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
include: {
originGoodTags: {
include: {
tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } },
},
orderBy: { tagId: 'asc' },
},
},
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
return {
tagsManual: og.tagsManual,
tags: og.originGoodTags.map((r) => ({
id: r.tag.id.toString(),
tagName: r.tag.tagName,
tagColor: r.tag.tagColor,
tagFontColor: r.tag.tagFontColor,
manual: r.manual,
})),
};
}
/**
* 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖),
* 并把有效标签镜像到该链接名下的商品。
*/
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
select: { id: true },
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
Number(a - b),
);
if (uniqueIds.length) {
const count = await this.prisma.tag.count({ where: { id: { in: uniqueIds } } });
if (count !== uniqueIds.length) {
throw new BadRequestException('存在无效标签');
}
}
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
...(!uniqueIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: uniqueIds.map((tagId) => ({
originGoodId: id,
tagId,
manual: true,
})),
}),
]),
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
]);
await this.familyRecompute.mirrorLinkTagsToGoods(id);
return this.getTags(id);
}
/** 恢复自动:清掉全部标签行(含人工行),回到按链接名称派生 */
async resetTags(id: bigint): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
select: { id: true, familyId: true },
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }),
]);
if (og.familyId) {
// 族内链接:整族重派生 + 商品镜像
await this.familyRecompute.syncFamilyTags(og.familyId);
} else {
await this.familyRecompute.refreshLinkTags(id);
}
return this.getTags(id);
}
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query;