v4.0.9版本更新-门店分类支持多选
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-09-02 11:12:51 +08:00
parent 77e9917a18
commit fe557c912d
18 changed files with 742 additions and 239 deletions
@@ -0,0 +1,21 @@
-- 门店多分类关联表(v4.0.9+)
-- 执行:mysql ... < migrate-store-category-link.sql
CREATE TABLE IF NOT EXISTS store_category_link (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
store_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
priority INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_store_category_link (store_id, category_id),
KEY idx_store_category_link_category (category_id),
CONSTRAINT fk_store_category_link_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE CASCADE,
CONSTRAINT fk_store_category_link_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 从现有主分类回填
INSERT IGNORE INTO store_category_link (store_id, category_id, priority)
SELECT id, category_id, 0
FROM store_store
WHERE category_id IS NOT NULL;
+18
View File
@@ -1000,12 +1000,29 @@ model CommonStoreCategory {
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children CommonStoreCategory[] @relation("StoreCategoryTree")
stores Store[]
storeLinks StoreCategoryLink[]
@@index([parentId, sort])
@@index([status])
@@map("common_store_category")
}
/// 门店 ↔ 二级分类多对多;store.category_id 保留主分类(排序第一)
model StoreCategoryLink {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
categoryId BigInt @map("category_id") @db.UnsignedBigInt
priority Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
category CommonStoreCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@unique([storeId, categoryId])
@@index([categoryId])
@@map("store_category_link")
}
model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
@@ -1542,6 +1559,7 @@ model Store {
packages StorePackage[]
packageChangeRequests StorePackageChangeRequest[]
infoChangeRequests StoreInfoChangeRequest[]
categoryLinks StoreCategoryLink[]
@@index([cityId, status])
@@index([partnerAccountId])
@@ -14,6 +14,12 @@ import { contractMediaType, normalizeContractUrls } from '../../common/store-med
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
import {
attachStoreCategories,
parseUniqueCategoryIds,
storeCategoryLinkInclude,
syncStoreCategoryLinks,
} from '../store/store-category-link.util';
import { AnalyticsService } from '../analytics/analytics.service';
import type {
CreateStoreAccountDto,
@@ -123,6 +129,7 @@ export class AdminStoresService {
cityRef: { select: { id: true, name: true, code: true } },
partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } },
category: { select: { id: true, name: true, parentId: true } },
...storeCategoryLinkInclude,
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
@@ -169,7 +176,7 @@ export class AdminStoresService {
return serializeBigInt({
items: items.map((s) => {
const { visibilityPhones, ...rest } = s;
return mapStoreCompat({
return mapStoreCompat(attachStoreCategories({
...rest,
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
@@ -180,7 +187,7 @@ export class AdminStoresService {
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
});
}));
}),
total,
page,
@@ -196,6 +203,7 @@ export class AdminStoresService {
cityRef: true,
partnerAccount: true,
category: true,
...storeCategoryLinkInclude,
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
@@ -219,7 +227,7 @@ export class AdminStoresService {
}),
]);
const { visibilityPhones, ...rest } = store;
return serializeBigInt(mapStoreCompat({
return serializeBigInt(mapStoreCompat(attachStoreCategories({
...rest,
visibilityWhitelistEnabled: store.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
@@ -235,7 +243,7 @@ export class AdminStoresService {
redeemCount: store._count.redeemRecords,
ratingCount: store._count.ratings,
_count: undefined,
}));
})));
}
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) {
@@ -430,11 +438,22 @@ export class AdminStoresService {
}
let categoryId: bigint | undefined;
if (dto.categoryId !== undefined) {
let categoryIds: bigint[] | undefined;
if (dto.categoryIds !== undefined) {
categoryIds = parseUniqueCategoryIds(dto.categoryIds);
if (!categoryIds.length) {
throw new BadRequestException('请选择门店分类');
}
for (const id of categoryIds) {
await this.storeCategoryService.assertLeafCategoryId(id);
}
categoryId = categoryIds[0];
} else if (dto.categoryId !== undefined) {
if (!dto.categoryId?.trim()) {
throw new BadRequestException('请选择门店分类');
}
categoryId = BigInt(dto.categoryId);
categoryIds = [categoryId];
await this.storeCategoryService.assertLeafCategoryId(categoryId);
}
@@ -661,6 +680,10 @@ export class AdminStoresService {
});
}
}
if (categoryIds !== undefined) {
await syncStoreCategoryLinks(tx, id, categoryIds);
}
});
return this.detailStore(id, actorId);
@@ -693,11 +716,20 @@ export class AdminStoresService {
if (!city) throw new BadRequestException('开城城市不存在');
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
if (!dto.categoryId?.trim()) {
if (!dto.categoryId?.trim() && (!dto.categoryIds || !dto.categoryIds.length)) {
throw new BadRequestException('请选择门店分类');
}
const categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const categoryIds = parseUniqueCategoryIds(dto.categoryIds);
if (!categoryIds.length && dto.categoryId?.trim()) {
categoryIds.push(BigInt(dto.categoryId));
}
if (!categoryIds.length) {
throw new BadRequestException('请选择门店分类');
}
for (const id of categoryIds) {
await this.storeCategoryService.assertLeafCategoryId(id);
}
const categoryId = categoryIds[0];
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
@@ -775,6 +807,8 @@ export class AdminStoresService {
},
});
await syncStoreCategoryLinks(this.prisma, store.id, categoryIds);
if (dto.coverUrl) {
const cover = await this.prisma.commonResource.create({
data: {
@@ -45,9 +45,15 @@ export class CreateStoreDto {
@IsString()
contactPhone?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
categoryId: string;
categoryId?: string;
/** 多选二级分类;至少选一项 */
@IsOptional()
@IsArray()
@IsString({ each: true })
categoryIds?: string[];
@IsOptional()
@IsString()
@@ -240,6 +246,12 @@ export class UpdateStoreDto {
@IsString()
categoryId?: string;
/** 多选二级分类;传此项时覆盖 categoryId */
@IsOptional()
@IsArray()
@IsString({ each: true })
categoryIds?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@@ -0,0 +1,130 @@
import type { Prisma } from '@prisma/client';
export const storeCategoryLinkInclude = {
categoryLinks: {
include: {
category: { include: { parent: true } },
},
orderBy: [{ priority: 'asc' as const }, { id: 'asc' as const }],
},
} satisfies Prisma.StoreInclude;
export type StoreCategoryItem = {
id: string;
name: string;
parentId: string | null;
parent: { id: string; name: string } | null;
priority: number;
};
type CategoryLinkRow = {
priority: number;
category: {
id: bigint;
name: string;
parentId: bigint | null;
parent?: { id: bigint; name: string } | null;
};
};
export function mapStoreCategoryLinks(links: CategoryLinkRow[] | undefined): StoreCategoryItem[] {
return (links ?? []).map((link) => ({
id: String(link.category.id),
name: link.category.name,
parentId: link.category.parentId != null ? String(link.category.parentId) : null,
parent: link.category.parent
? { id: String(link.category.parent.id), name: link.category.parent.name }
: null,
priority: link.priority,
}));
}
export function attachStoreCategories<
T extends {
categoryId?: bigint | null;
category?: {
id: bigint;
name: string;
parentId?: bigint | null;
parent?: { id: bigint; name: string } | null;
} | null;
categoryLinks?: CategoryLinkRow[];
},
>(store: T) {
const { categoryLinks, ...rest } = store;
let categories = mapStoreCategoryLinks(categoryLinks);
if (!categories.length && store.category) {
categories = [
{
id: String(store.category.id),
name: store.category.name,
parentId: store.category.parentId != null ? String(store.category.parentId) : null,
parent: store.category.parent
? { id: String(store.category.parent.id), name: store.category.parent.name }
: null,
priority: 0,
},
];
}
const primary = categories[0] ?? null;
return {
...rest,
categories,
categoryId: primary?.id ?? (store.categoryId != null ? String(store.categoryId) : null),
category: primary
? {
id: primary.id,
name: primary.name,
parentId: primary.parentId,
parent: primary.parent,
}
: store.category
? {
id: String(store.category.id),
name: store.category.name,
parentId: store.category.parentId != null ? String(store.category.parentId) : null,
parent: store.category.parent
? { id: String(store.category.parent.id), name: store.category.parent.name }
: null,
}
: null,
};
}
export async function syncStoreCategoryLinks(
tx: Prisma.TransactionClient,
storeId: bigint,
categoryIds: bigint[],
) {
const uniqueIds = [...new Set(categoryIds.map((id) => id.toString()))].map(BigInt);
await tx.storeCategoryLink.deleteMany({ where: { storeId } });
if (uniqueIds.length === 0) {
await tx.store.update({ where: { id: storeId }, data: { categoryId: null } });
return;
}
await tx.storeCategoryLink.createMany({
data: uniqueIds.map((categoryId, index) => ({
storeId,
categoryId,
priority: index,
})),
});
await tx.store.update({
where: { id: storeId },
data: { categoryId: uniqueIds[0] },
});
}
export function parseUniqueCategoryIds(raw: unknown): bigint[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const ids: bigint[] = [];
for (const item of raw) {
const id = BigInt(String(item));
const key = id.toString();
if (seen.has(key)) continue;
seen.add(key);
ids.push(id);
}
return ids;
}
@@ -253,7 +253,11 @@ export class StoreCategoryService {
if (childCount > 0) {
throw new BadRequestException('请先删除或停用下级分类');
}
const storeCount = await this.prisma.store.count({ where: { categoryId: id } });
const storeCount = await this.prisma.store.count({
where: {
OR: [{ categoryId: id }, { categoryLinks: { some: { categoryId: id } } }],
},
});
if (storeCount > 0) {
// 软停用,避免破坏已有门店关联
const row = await this.prisma.commonStoreCategory.update({
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { StoreService } from './store.service';
import { StoreCategoryService } from './store-category.service';
import { RedeemService } from '../redeem/redeem.service';
@@ -109,41 +109,51 @@ export class PartnerStoreController {
}
@Get(':id')
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
}
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
}
@Get(':id/categories')
async getCategories(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.storeService.getStoreCategories(BigInt(id));
}
@Get(':id/categories')
async getCategories(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.storeService.partnerGetStoreCategories(user.actorId, BigInt(id));
}
@Post(':id/categories')
async assignCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { categoryId: string; priority?: number },
) {
return this.storeService.assignCategoryToStore(BigInt(id), body.categoryId, body.priority);
}
@Post(':id/categories')
async assignCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { categoryId: string; priority?: number },
) {
return this.storeService.partnerAssignCategoryToStore(
user.actorId,
BigInt(id),
body.categoryId,
body.priority,
);
}
@Delete(':id/categories/:categoryId')
async removeCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('categoryId') categoryId: string,
) {
return this.storeService.removeCategoryFromStore(BigInt(id), categoryId);
}
@Delete(':id/categories/:categoryId')
async removeCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('categoryId') categoryId: string,
) {
return this.storeService.partnerRemoveCategoryFromStore(user.actorId, BigInt(id), categoryId);
}
@Put(':id/categories')
async replaceCategories(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { categoryIds: string[]; priorities?: Record<string, number> },
) {
return this.storeService.replaceStoreCategories(BigInt(id), body.categoryIds, body.priorities);
}
@Put(':id/categories')
async replaceCategories(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { categoryIds: string[]; priorities?: Record<string, number> },
) {
return this.storeService.partnerReplaceStoreCategories(
user.actorId,
BigInt(id),
body.categoryIds,
body.priorities,
);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
@@ -18,6 +18,12 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service';
import {
attachStoreCategories,
parseUniqueCategoryIds,
storeCategoryLinkInclude,
syncStoreCategoryLinks,
} from './store-category-link.util';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import {
TestWhitelistService,
@@ -185,6 +191,7 @@ export class StoreService {
where: where as never,
include: {
category: { include: { parent: true } },
...storeCategoryLinkInclude,
coverResource: true,
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
@@ -226,11 +233,11 @@ export class StoreService {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat(
{
attachStoreCategories({
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
},
}),
{ publicDial: true },
);
const distanceMeters =
@@ -263,6 +270,7 @@ export class StoreService {
where: { id, status: 'OPEN' },
include: {
category: { include: { parent: true } },
...storeCategoryLinkInclude,
coverResource: true,
},
});
@@ -287,7 +295,7 @@ export class StoreService {
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat(
{
attachStoreCategories({
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
@@ -310,7 +318,7 @@ export class StoreService {
sortOrder: p.sortOrder,
};
}),
},
}),
{ publicDial: true },
),
);
@@ -332,17 +340,17 @@ export class StoreService {
}
const stores = await this.prisma.store.findMany({
where,
include: { category: true, coverResource: true },
include: { category: true, ...storeCategoryLinkInclude, coverResource: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return serializeBigInt(stores.map((s) => mapStoreCompat(s)));
return serializeBigInt(stores.map((s) => mapStoreCompat(attachStoreCategories(s))));
}
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
include: { category: true, coverResource: true },
include: { category: true, ...storeCategoryLinkInclude, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
if (this.isSubAccount(account)) {
@@ -350,7 +358,7 @@ export class StoreService {
}
const media = await this.loadPartnerStoreMedia(storeId);
return serializeBigInt(mapStoreCompat({ ...store, media }));
return serializeBigInt(mapStoreCompat(attachStoreCategories({ ...store, media })));
}
async partnerListCities(partnerAccountId: bigint) {
@@ -444,11 +452,20 @@ export class StoreService {
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
if (!body.categoryId) {
if (!body.categoryId && !Array.isArray(body.categoryIds)) {
throw new BadRequestException('请选择店铺类型');
}
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const categoryIds = parseUniqueCategoryIds(body.categoryIds);
if (!categoryIds.length && body.categoryId) {
categoryIds.push(parseBigIntParam(body.categoryId, '分类ID'));
}
if (!categoryIds.length) {
throw new BadRequestException('请选择店铺类型');
}
for (const id of categoryIds) {
await this.storeCategoryService.assertLeafCategoryId(id);
}
const categoryId = categoryIds[0];
const openTime = body.openTime ? String(body.openTime).trim() : '10:00';
const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00';
@@ -507,6 +524,8 @@ export class StoreService {
},
});
await syncStoreCategoryLinks(this.prisma, store.id, categoryIds);
if (latitude == null || longitude == null) {
await this.ensureStoreCoordinates(store);
}
@@ -1515,4 +1534,138 @@ export class StoreService {
});
if (!event) throw new ForbiddenException('无权查看该门店');
}
async getStoreCategories(storeId: bigint) {
const store = await this.prisma.store.findUnique({
where: { id: storeId },
include: {
category: { include: { parent: true } },
...storeCategoryLinkInclude,
},
});
if (!store) throw new NotFoundException('门店不存在');
return serializeBigInt(attachStoreCategories(store).categories);
}
async partnerGetStoreCategories(partnerAccountId: bigint, storeId: bigint) {
await this.partnerGetStore(partnerAccountId, storeId);
return this.getStoreCategories(storeId);
}
async assignCategoryToStore(storeId: bigint, categoryIdRaw: string, priority?: number) {
const categoryId = parseBigIntParam(categoryIdRaw, '分类ID');
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
if (!store) throw new NotFoundException('门店不存在');
const existing = await this.prisma.storeCategoryLink.findUnique({
where: { storeId_categoryId: { storeId, categoryId } },
});
if (existing) {
if (priority != null) {
await this.prisma.storeCategoryLink.update({
where: { id: existing.id },
data: { priority },
});
}
return this.getStoreCategories(storeId);
}
const nextPriority =
priority ??
((await this.prisma.storeCategoryLink.count({ where: { storeId } })) || 0);
await this.prisma.$transaction(async (tx) => {
await tx.storeCategoryLink.create({
data: { storeId, categoryId, priority: nextPriority },
});
if (!store.categoryId) {
await tx.store.update({ where: { id: storeId }, data: { categoryId } });
}
});
return this.getStoreCategories(storeId);
}
async partnerAssignCategoryToStore(
partnerAccountId: bigint,
storeId: bigint,
categoryId: string,
priority?: number,
) {
await this.partnerGetStore(partnerAccountId, storeId);
return this.assignCategoryToStore(storeId, categoryId, priority);
}
async removeCategoryFromStore(storeId: bigint, categoryIdRaw: string) {
const categoryId = parseBigIntParam(categoryIdRaw, '分类ID');
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
if (!store) throw new NotFoundException('门店不存在');
const linkCount = await this.prisma.storeCategoryLink.count({ where: { storeId } });
const legacyOnly = linkCount === 0 && store.categoryId?.toString() === categoryId.toString();
if (linkCount <= 1 && !legacyOnly) {
throw new BadRequestException('门店至少保留一个分类');
}
await this.prisma.$transaction(async (tx) => {
await tx.storeCategoryLink.deleteMany({ where: { storeId, categoryId } });
const remaining = await tx.storeCategoryLink.findMany({
where: { storeId },
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
});
const nextPrimary = remaining[0]?.categoryId ?? null;
await tx.store.update({
where: { id: storeId },
data: { categoryId: nextPrimary },
});
});
return this.getStoreCategories(storeId);
}
async partnerRemoveCategoryFromStore(
partnerAccountId: bigint,
storeId: bigint,
categoryId: string,
) {
await this.partnerGetStore(partnerAccountId, storeId);
return this.removeCategoryFromStore(storeId, categoryId);
}
async replaceStoreCategories(
storeId: bigint,
categoryIdsRaw: string[],
priorities?: Record<string, number>,
) {
if (!categoryIdsRaw?.length) {
throw new BadRequestException('请至少选择一个门店分类');
}
const parsed = parseUniqueCategoryIds(categoryIdsRaw);
if (!parsed.length) {
throw new BadRequestException('请至少选择一个门店分类');
}
for (const id of parsed) {
await this.storeCategoryService.assertLeafCategoryId(id);
}
const sorted = [...parsed].sort((a, b) => {
const pa = priorities?.[a.toString()] ?? 0;
const pb = priorities?.[b.toString()] ?? 0;
if (pa !== pb) return pa - pb;
return Number(a - b);
});
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
if (!store) throw new NotFoundException('门店不存在');
await this.prisma.$transaction(async (tx) => {
await syncStoreCategoryLinks(tx, storeId, sorted);
});
return this.getStoreCategories(storeId);
}
async partnerReplaceStoreCategories(
partnerAccountId: bigint,
storeId: bigint,
categoryIds: string[],
priorities?: Record<string, number>,
) {
await this.partnerGetStore(partnerAccountId, storeId);
return this.replaceStoreCategories(storeId, categoryIds, priorities);
}
}