feat(deploy): production deployment setup and fixes

- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
@@ -1,67 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@ApiTags('categories')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('categories')
export class CategoriesController {
constructor(private readonly service: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'Return categories as a tree' })
findAll() {
return this.service.findAll();
}
@Get('flat')
@ApiOperation({ summary: 'Return categories as a flat list' })
findFlat() {
return this.service.findFlat();
}
@Get(':id')
@ApiOperation({ summary: 'Get one category' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a category' })
create(@Body() dto: CreateCategoryDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a category' })
update(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateCategoryDto,
) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a category' })
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
}
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@ApiTags('categories')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('categories')
export class CategoriesController {
constructor(private readonly service: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'Return categories as a tree' })
findAll() {
return this.service.findAll();
}
@Get('flat')
@ApiOperation({ summary: 'Return categories as a flat list' })
findFlat() {
return this.service.findFlat();
}
@Get(':id')
@ApiOperation({ summary: 'Get one category' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a category' })
create(@Body() dto: CreateCategoryDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a category' })
update(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateCategoryDto,
) {
return this.service.update(BigInt(id), dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a category' })
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
}
+10 -10
View File
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}
+103 -103
View File
@@ -1,103 +1,103 @@
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { PrismaService } from '../prisma/prisma.service';
describe('CategoriesService', () => {
let service: CategoriesService;
let prisma: PrismaService;
const createdNames: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CategoriesService, PrismaService],
}).compile();
service = moduleRef.get(CategoriesService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (createdNames.length) {
await prisma.category.deleteMany({
where: { categoryName: { in: createdNames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates root + child + grandchild and assembles them into a tree', async () => {
const stamp = Date.now();
const rootName = `Root ${stamp}`;
const childName = `Child ${stamp}`;
const leafName = `Leaf ${stamp}`;
createdNames.push(rootName, childName, leafName);
const root = await service.create({ categoryName: rootName });
const child = await service.create({
categoryName: childName,
parentCategoryId: Number(root.id),
});
const leaf = await service.create({
categoryName: leafName,
parentCategoryId: Number(child.id),
});
const tree = await service.findAll();
const findNode = (
list: Array<{ id: string; children: Array<{ id: string }> }>,
id: string,
): { id: string; children: Array<{ id: string }> } | undefined => {
for (const n of list) {
if (n.id === id) return n;
const inner = findNode(n.children as any, id);
if (inner) return inner;
}
return undefined;
};
const rootNode = findNode(tree as any, root.id.toString());
expect(rootNode).toBeDefined();
const childNode = findNode(rootNode!.children as any, child.id.toString());
expect(childNode).toBeDefined();
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
expect(leafNode).toBeDefined();
});
it('rejects deletion when children exist', async () => {
const stamp = Date.now();
const parent = await service.create({ categoryName: `Parent ${stamp}` });
createdNames.push(parent.categoryName);
const child = await service.create({
categoryName: `Child of ${stamp}`,
parentCategoryId: Number(parent.id),
});
createdNames.push(child.categoryName);
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('returns a flat list and a single node', async () => {
const stamp = Date.now();
const c = await service.create({ categoryName: `Flat ${stamp}` });
createdNames.push(c.categoryName);
const flat = await service.findFlat();
expect(flat.some((row) => row.id === c.id)).toBe(true);
const single = await service.findOne(c.id);
expect(single.categoryName).toBe(c.categoryName);
});
});
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { PrismaService } from '../prisma/prisma.service';
describe('CategoriesService', () => {
let service: CategoriesService;
let prisma: PrismaService;
const createdNames: string[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [CategoriesService, PrismaService],
}).compile();
service = moduleRef.get(CategoriesService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
if (createdNames.length) {
await prisma.category.deleteMany({
where: { categoryName: { in: createdNames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates root + child + grandchild and assembles them into a tree', async () => {
const stamp = Date.now();
const rootName = `Root ${stamp}`;
const childName = `Child ${stamp}`;
const leafName = `Leaf ${stamp}`;
createdNames.push(rootName, childName, leafName);
const root = await service.create({ categoryName: rootName });
const child = await service.create({
categoryName: childName,
parentCategoryId: Number(root.id),
});
const leaf = await service.create({
categoryName: leafName,
parentCategoryId: Number(child.id),
});
const tree = await service.findAll();
const findNode = (
list: Array<{ id: string; children: Array<{ id: string }> }>,
id: string,
): { id: string; children: Array<{ id: string }> } | undefined => {
for (const n of list) {
if (n.id === id) return n;
const inner = findNode(n.children as any, id);
if (inner) return inner;
}
return undefined;
};
const rootNode = findNode(tree as any, root.id.toString());
expect(rootNode).toBeDefined();
const childNode = findNode(rootNode!.children as any, child.id.toString());
expect(childNode).toBeDefined();
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
expect(leafNode).toBeDefined();
});
it('rejects deletion when children exist', async () => {
const stamp = Date.now();
const parent = await service.create({ categoryName: `Parent ${stamp}` });
createdNames.push(parent.categoryName);
const child = await service.create({
categoryName: `Child of ${stamp}`,
parentCategoryId: Number(parent.id),
});
createdNames.push(child.categoryName);
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('returns a flat list and a single node', async () => {
const stamp = Date.now();
const c = await service.create({ categoryName: `Flat ${stamp}` });
createdNames.push(c.categoryName);
const flat = await service.findFlat();
expect(flat.some((row) => row.id === c.id)).toBe(true);
const single = await service.findOne(c.id);
expect(single.categoryName).toBe(c.categoryName);
});
});
+137 -137
View File
@@ -1,137 +1,137 @@
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { CategoryNodeDto } from './dto/category-node.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(): Promise<CategoryNodeDto[]> {
const all = await this.prisma.category.findMany({
orderBy: [{ id: 'asc' }],
});
return this.buildTree(all);
}
async findFlat() {
return this.prisma.category.findMany({
orderBy: [{ id: 'asc' }],
});
}
async findOne(id: bigint) {
const c = await this.prisma.category.findUnique({ where: { id } });
if (!c) throw new NotFoundException(`Category ${id} not found`);
return c;
}
async create(dto: CreateCategoryDto) {
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
// Validate the parent exists to produce a clean 404 instead of FK error.
await this.findOne(BigInt(dto.parentCategoryId));
}
return this.prisma.category.create({
data: {
categoryName: dto.categoryName,
categoryIcon: dto.categoryIcon ?? null,
parentCategoryId:
dto.parentCategoryId === undefined || dto.parentCategoryId === null
? null
: BigInt(dto.parentCategoryId),
},
});
}
async update(id: bigint, dto: UpdateCategoryDto) {
await this.findOne(id);
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
// Prevent self-parenting & cycles.
if (BigInt(dto.parentCategoryId) === id) {
throw new BadRequestException('A category cannot be its own parent');
}
await this.findOne(BigInt(dto.parentCategoryId));
}
const data: Prisma.CategoryUpdateInput = {};
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
if (dto.parentCategoryId !== undefined) {
data.parent = dto.parentCategoryId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.parentCategoryId) } };
}
return this.prisma.category.update({ where: { id }, data });
}
async remove(id: bigint) {
await this.findOne(id);
const childCount = await this.prisma.category.count({
where: { parentCategoryId: id },
});
if (childCount > 0) {
throw new BadRequestException(
'Category has children and cannot be deleted',
);
}
try {
return await this.prisma.category.delete({ where: { id } });
} catch (err) {
if (this.isForeignKeyViolation(err)) {
throw new BadRequestException(
'Category is referenced by goods or positions and cannot be deleted',
);
}
throw err;
}
}
private isForeignKeyViolation(err: unknown): boolean {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return err.code === 'P2003';
}
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
const msg = err.message ?? '';
return (
msg.includes('foreign key constraint') ||
msg.includes('RESTRICT') ||
msg.includes('violates')
);
}
return false;
}
/**
* Builds a tree in-memory from a flat list. Top-level nodes have
* `parentCategoryId = null`.
*/
private buildTree(
rows: PrismaCategory[],
): CategoryNodeDto[] {
const byId = new Map<bigint, CategoryNodeDto>();
for (const row of rows) {
byId.set(row.id, CategoryNodeDto.from(row, []));
}
const roots: CategoryNodeDto[] = [];
for (const row of rows) {
const node = byId.get(row.id)!;
if (row.parentCategoryId === null) {
roots.push(node);
} else {
const parent = byId.get(row.parentCategoryId);
if (parent) {
parent.children.push(node);
} else {
// Orphan (parent row missing) — surface as a root.
roots.push(node);
}
}
}
return roots;
}
}
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { CategoryNodeDto } from './dto/category-node.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(): Promise<CategoryNodeDto[]> {
const all = await this.prisma.category.findMany({
orderBy: [{ id: 'asc' }],
});
return this.buildTree(all);
}
async findFlat() {
return this.prisma.category.findMany({
orderBy: [{ id: 'asc' }],
});
}
async findOne(id: bigint) {
const c = await this.prisma.category.findUnique({ where: { id } });
if (!c) throw new NotFoundException(`Category ${id} not found`);
return c;
}
async create(dto: CreateCategoryDto) {
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
// Validate the parent exists to produce a clean 404 instead of FK error.
await this.findOne(BigInt(dto.parentCategoryId));
}
return this.prisma.category.create({
data: {
categoryName: dto.categoryName,
categoryIcon: dto.categoryIcon ?? null,
parentCategoryId:
dto.parentCategoryId === undefined || dto.parentCategoryId === null
? null
: BigInt(dto.parentCategoryId),
},
});
}
async update(id: bigint, dto: UpdateCategoryDto) {
await this.findOne(id);
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
// Prevent self-parenting & cycles.
if (BigInt(dto.parentCategoryId) === id) {
throw new BadRequestException('A category cannot be its own parent');
}
await this.findOne(BigInt(dto.parentCategoryId));
}
const data: Prisma.CategoryUpdateInput = {};
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
if (dto.parentCategoryId !== undefined) {
data.parent = dto.parentCategoryId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.parentCategoryId) } };
}
return this.prisma.category.update({ where: { id }, data });
}
async remove(id: bigint) {
await this.findOne(id);
const childCount = await this.prisma.category.count({
where: { parentCategoryId: id },
});
if (childCount > 0) {
throw new BadRequestException(
'Category has children and cannot be deleted',
);
}
try {
return await this.prisma.category.delete({ where: { id } });
} catch (err) {
if (this.isForeignKeyViolation(err)) {
throw new BadRequestException(
'Category is referenced by goods or positions and cannot be deleted',
);
}
throw err;
}
}
private isForeignKeyViolation(err: unknown): boolean {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
return err.code === 'P2003';
}
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
const msg = err.message ?? '';
return (
msg.includes('foreign key constraint') ||
msg.includes('RESTRICT') ||
msg.includes('violates')
);
}
return false;
}
/**
* Builds a tree in-memory from a flat list. Top-level nodes have
* `parentCategoryId = null`.
*/
private buildTree(
rows: PrismaCategory[],
): CategoryNodeDto[] {
const byId = new Map<bigint, CategoryNodeDto>();
for (const row of rows) {
byId.set(row.id, CategoryNodeDto.from(row, []));
}
const roots: CategoryNodeDto[] = [];
for (const row of rows) {
const node = byId.get(row.id)!;
if (row.parentCategoryId === null) {
roots.push(node);
} else {
const parent = byId.get(row.parentCategoryId);
if (parent) {
parent.children.push(node);
} else {
// Orphan (parent row missing) — surface as a root.
roots.push(node);
}
}
}
return roots;
}
}
@@ -1,35 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Category as PrismaCategory } from '@prisma/client';
export class CategoryNodeDto {
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
id!: string;
@ApiProperty()
categoryName!: string;
@ApiProperty({ nullable: true })
categoryIcon!: string | null;
@ApiProperty({ nullable: true })
sdsCategoryId!: string | null;
@ApiProperty({ nullable: true, description: 'Parent category ID' })
parentCategoryId!: string | null;
@ApiProperty({ type: [CategoryNodeDto] })
children!: CategoryNodeDto[];
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
return {
id: category.id.toString(),
categoryName: category.categoryName,
categoryIcon: category.categoryIcon,
sdsCategoryId: category.sdsCategoryId,
parentCategoryId: category.parentCategoryId
? category.parentCategoryId.toString()
: null,
children,
};
}
}
import { ApiProperty } from '@nestjs/swagger';
import type { Category as PrismaCategory } from '@prisma/client';
export class CategoryNodeDto {
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
id!: string;
@ApiProperty()
categoryName!: string;
@ApiProperty({ nullable: true })
categoryIcon!: string | null;
@ApiProperty({ nullable: true })
sdsCategoryId!: string | null;
@ApiProperty({ nullable: true, description: 'Parent category ID' })
parentCategoryId!: string | null;
@ApiProperty({ type: [CategoryNodeDto] })
children!: CategoryNodeDto[];
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
return {
id: category.id.toString(),
categoryName: category.categoryName,
categoryIcon: category.categoryIcon,
sdsCategoryId: category.sdsCategoryId,
parentCategoryId: category.parentCategoryId
? category.parentCategoryId.toString()
: null,
children,
};
}
}
@@ -1,26 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateCategoryDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
categoryName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
categoryIcon?: string;
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
@IsOptional()
@IsInt()
@Min(1)
parentCategoryId?: number;
}
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateCategoryDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
categoryName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
categoryIcon?: string;
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
@IsOptional()
@IsInt()
@Min(1)
parentCategoryId?: number;
}
@@ -1,25 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateCategoryDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
categoryName?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
categoryIcon?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
parentCategoryId?: number | null;
}
import { ApiProperty } from '@nestjs/swagger';
import {
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateCategoryDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
categoryName?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
categoryIcon?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
parentCategoryId?: number | null;
}