feat(v3.4.15): HQ store media edit and package images up to 20

Allow HQ to replace/delete store photos; support multi-image packages with a 20-image cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 19:24:07 +08:00
parent 0692bebf07
commit 76375eff83
19 changed files with 657 additions and 302 deletions
+2
View File
@@ -1325,6 +1325,8 @@ model StorePackage {
usableTime String? @map("usable_time") @db.VarChar(256)
otherNotes String? @map("other_notes") @db.VarChar(512)
imageUrl String? @map("image_url") @db.VarChar(512)
/// 套餐多图 URL 列表(JSON string[]),最多 20 张;imageUrl 同步为首图
imageUrls Json? @map("image_urls")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -373,11 +373,24 @@ export class AdminStoresService {
// 分实体手机号已废弃,忽略写入
}
if (dto.coverUrl) {
if (current.coverResourceId) {
if (dto.coverUrl !== undefined) {
const coverUrl = dto.coverUrl?.trim() || '';
if (!coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { status: 'DELETED' },
});
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
}
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
} else if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await tx.commonResource.create({
@@ -387,14 +400,61 @@ export class AdminStoresService {
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
ossKey: coverUrl,
url: coverUrl,
},
});
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
if (dto.envPhotoUrls !== undefined) {
const envUrls = [...new Set(
(dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean),
)].slice(0, 20);
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
for (let i = 0; i < envUrls.length; i++) {
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: envUrls[i],
url: envUrls[i],
sortOrder: i,
},
});
}
}
if (dto.contractUrl !== undefined) {
const contractUrl = dto.contractUrl?.trim() || '';
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
if (contractUrl) {
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'CONTRACT',
mediaType: isPdf ? 'FILE' : 'IMAGE',
ossBucket: 'legacy',
ossKey: contractUrl,
url: contractUrl,
sortOrder: 0,
},
});
}
}
if (primaryBinding?.storeAccount) {
const account = primaryBinding.storeAccount;
const accountData: {
@@ -545,7 +605,7 @@ export class AdminStoresService {
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
}
const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
const envUrls = [...new Set((dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean))].slice(0, 20);
for (let i = 0; i < envUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
@@ -96,6 +97,7 @@ export class CreateStoreDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
envPhotoUrls?: string[];
@IsOptional()
@@ -164,7 +166,17 @@ export class UpdateStoreDto {
@IsOptional()
@IsString()
coverUrl?: string;
coverUrl?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
envPhotoUrls?: string[];
@IsOptional()
@IsString()
contractUrl?: string | null;
@IsOptional()
@IsString()
@@ -4,7 +4,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { STORE_PACKAGE_MAX_COUNT, type StorePackageItemDto } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
type StorePackageItemDto,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
@@ -44,9 +49,18 @@ export class StorePackageService {
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
? String(item.otherNotes).trim()
: null;
const imageUrl = item.imageUrl != null && String(item.imageUrl).trim()
? String(item.imageUrl).trim()
: null;
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: item.imageUrl as string | null | undefined,
imageUrls: item.imageUrls,
});
if (
Array.isArray(item.imageUrls) &&
item.imageUrls.filter((u) => String(u ?? '').trim()).length > STORE_PACKAGE_IMAGE_MAX_COUNT
) {
throw new BadRequestException(
`${index + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`,
);
}
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
return {
name,
@@ -54,7 +68,8 @@ export class StorePackageService {
dishes,
usableTime,
otherNotes,
imageUrl,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
};
}
@@ -67,8 +82,13 @@ export class StorePackageService {
usableTime: string | null;
otherNotes: string | null;
imageUrl: string | null;
imageUrls: Prisma.JsonValue | null;
sortOrder: number;
}) {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: row.imageUrl,
imageUrls: row.imageUrls,
});
return {
id: row.id.toString(),
name: row.name,
@@ -76,7 +96,8 @@ export class StorePackageService {
dishes: row.dishes,
usableTime: row.usableTime,
otherNotes: row.otherNotes,
imageUrl: row.imageUrl,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: row.sortOrder,
};
}
@@ -221,6 +242,9 @@ export class StorePackageService {
usableTime: pkg.usableTime ?? null,
otherNotes: pkg.otherNotes ?? null,
imageUrl: pkg.imageUrl ?? null,
imageUrls: pkg.imageUrls?.length
? (pkg.imageUrls as Prisma.InputJsonValue)
: Prisma.JsonNull,
sortOrder: pkg.sortOrder ?? index,
},
}),
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -230,15 +231,22 @@ export class StoreService {
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,
packages: packageRows.map((p) => ({
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
imageUrl: p.imageUrl,
sortOrder: p.sortOrder,
})),
packages: packageRows.map((p) => {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: p.imageUrl,
imageUrls: p.imageUrls,
});
return {
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: p.sortOrder,
};
}),
}),
);
}
@@ -1292,6 +1300,7 @@ export class StoreService {
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
if (urls.length >= 20) break;
}
return urls;
}