diff --git a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.test.ts b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.test.ts index 26a121ba..130e808b 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.test.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.test.ts @@ -1,6 +1,7 @@ import { cleanupSharedNonIntegratedAiWhiteBackgroundManifest, generateSharedNonIntegratedAiWhiteBackgroundSession, + listSharedNonIntegratedProductPage, uploadDetachedNonIntegratedFile, uploadSharedNonIntegratedAiWhiteBackgroundSession, } from './nonIntegratedApi' @@ -80,6 +81,34 @@ describe('nonIntegratedApi uploads', () => { restoreXMLHttpRequest() }) + it('clamps list pageSize to 100 before sending the request', async () => { + const fetchMock = jest.fn(async () => + jsonResponse({ + success: true, + data: { + products: [], + pageInfo: { + page: 1, + pageSize: 100, + total: 0, + totalPages: 1, + }, + }, + }), + ) + setFetch(fetchMock as typeof fetch) + + await listSharedNonIntegratedProductPage({ + page: 1, + pageSize: 1000, + spu: 'SZ0242', + }) + + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/intake/non-integrated?page=1&pageSize=100&spu=SZ0242', + ) + }) + it('uploads detached files without a product id through the temporary upload endpoint', async () => { const fetchMock = jest.fn(async () => jsonResponse({ diff --git a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts index efa997dc..bae80032 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/core/nonIntegratedApi.ts @@ -19,6 +19,7 @@ type ApiEnvelope = { const NON_INTEGRATED_UPLOAD_CHUNK_BYTES = 512 * 1024 const NON_INTEGRATED_API_TIMEOUT_MS = 60_000 const NON_INTEGRATED_AI_WHITE_BACKGROUND_TIMEOUT_MS = 420_000 +const NON_INTEGRATED_MAX_LIST_PAGE_SIZE = 100 export type NonIntegratedUploadProgress = { loadedBytes: number @@ -211,6 +212,13 @@ function normalizeUploadPercent(value: number) { return Math.min(100, Math.max(0, Math.round(value))) } +function normalizeListPageSizeParam(value: unknown) { + const pageSize = Number(value) + if (!Number.isFinite(pageSize) || pageSize <= 0) return undefined + + return Math.min(NON_INTEGRATED_MAX_LIST_PAGE_SIZE, Math.max(1, Math.floor(pageSize))) +} + function reportFileUploadProgress( onProgress: NonIntegratedUploadProgressHandler | undefined, fileSize: number, @@ -293,7 +301,18 @@ export async function listSharedNonIntegratedProductPage( params: NonIntegratedProductListParams = {}, ) { const searchParams = new URLSearchParams() - Object.entries(params).forEach(([key, value]) => { + const normalizedParams: Record = { ...params } + + if (params.pageSize !== undefined) { + const pageSize = normalizeListPageSizeParam(params.pageSize) + if (pageSize === undefined) { + delete normalizedParams.pageSize + } else { + normalizedParams.pageSize = pageSize + } + } + + Object.entries(normalizedParams).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return searchParams.set(key, String(value)) }) diff --git a/furniture-3d-system/src/lib/intake-reference-library-client.test.ts b/furniture-3d-system/src/lib/intake-reference-library-client.test.ts new file mode 100644 index 00000000..2b9738d1 --- /dev/null +++ b/furniture-3d-system/src/lib/intake-reference-library-client.test.ts @@ -0,0 +1,100 @@ +import { + clearIntakeReferenceLibraryClientCacheForTests, + listIntakeReferenceLibraryItems, + replaceIntakeReferenceLibraryItems, +} from './intake-reference-library-client' +import type { IntakeReferenceLibraryItemDTO } from '@/server/services' + +const fetchMock = jest.fn() + +function createItem(overrides: Partial = {}): IntakeReferenceLibraryItemDTO { + return { + id: 'ACCESSORY-A001', + libraryType: 'ACCESSORY', + itemKey: 'A001', + code: 'A001', + name: '螺丝', + category: '配件', + letter: 'A', + payload: {}, + sortOrder: 0, + isActive: true, + createdBy: null, + updatedBy: null, + createdAt: '2026-06-16T00:00:00.000Z', + updatedAt: '2026-06-16T00:00:00.000Z', + ...overrides, + } +} + +function okResponse(items: IntakeReferenceLibraryItemDTO[]) { + return { + ok: true, + json: async () => ({ + success: true, + data: { items }, + }), + } as Response +} + +describe('intake-reference-library-client', () => { + let nowSpy: jest.SpyInstance + + beforeEach(() => { + clearIntakeReferenceLibraryClientCacheForTests() + fetchMock.mockReset() + fetchMock.mockResolvedValue(okResponse([createItem()])) + global.fetch = fetchMock as never + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0) + }) + + afterEach(() => { + nowSpy.mockRestore() + }) + + it('dedupes in-flight requests and reuses a fresh client cache entry', async () => { + const [first, second] = await Promise.all([ + listIntakeReferenceLibraryItems('ACCESSORY'), + listIntakeReferenceLibraryItems('ACCESSORY'), + ]) + const cached = await listIntakeReferenceLibraryItems('ACCESSORY') + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(first).toEqual(second) + expect(cached).toEqual(first) + }) + + it('expires the client cache after 5 minutes', async () => { + fetchMock + .mockResolvedValueOnce(okResponse([createItem({ itemKey: 'A001', code: 'A001' })])) + .mockResolvedValueOnce(okResponse([createItem({ itemKey: 'A002', code: 'A002' })])) + + const first = await listIntakeReferenceLibraryItems('ACCESSORY') + nowSpy.mockReturnValue(5 * 60 * 1000 + 1) + const second = await listIntakeReferenceLibraryItems('ACCESSORY') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(first[0]?.code).toBe('A001') + expect(second[0]?.code).toBe('A002') + }) + + it('invalidates same-type cached list after replaceAll', async () => { + fetchMock + .mockResolvedValueOnce(okResponse([createItem({ itemKey: 'OLD', code: 'OLD' })])) + .mockResolvedValueOnce(okResponse([createItem({ itemKey: 'NEW', code: 'NEW' })])) + .mockResolvedValueOnce(okResponse([createItem({ itemKey: 'FRESH', code: 'FRESH' })])) + + await listIntakeReferenceLibraryItems('ACCESSORY') + await replaceIntakeReferenceLibraryItems('ACCESSORY', [ + { + itemKey: 'NEW', + code: 'NEW', + name: '新配件', + }, + ]) + const fresh = await listIntakeReferenceLibraryItems('ACCESSORY') + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fresh[0]?.code).toBe('FRESH') + }) +}) diff --git a/furniture-3d-system/src/lib/intake-reference-library-client.ts b/furniture-3d-system/src/lib/intake-reference-library-client.ts index 5e2da8fc..fd2cd469 100644 --- a/furniture-3d-system/src/lib/intake-reference-library-client.ts +++ b/furniture-3d-system/src/lib/intake-reference-library-client.ts @@ -37,6 +37,22 @@ export type IntakeReferenceLibraryClientItem = { isActive?: boolean } +const REFERENCE_LIBRARY_CLIENT_CACHE_TTL_MS = 5 * 60 * 1000 + +type ReferenceLibraryClientCacheEntry = { + expiresAt: number + promise: Promise +} + +const referenceLibraryClientCache = new Map< + IntakeReferenceLibraryType, + ReferenceLibraryClientCacheEntry +>() + +export function clearIntakeReferenceLibraryClientCacheForTests() { + referenceLibraryClientCache.clear() +} + function readText(value: unknown) { return typeof value === 'string' ? value.trim() : '' } @@ -131,16 +147,37 @@ export async function replaceAlphabetReferenceLibraryItems( export async function listIntakeReferenceLibraryItems( libraryType: IntakeReferenceLibraryType, ): Promise { - const data = await requestReferenceLibrary<{ items: IntakeReferenceLibraryItemDTO[] }>( + const now = Date.now() + const cached = referenceLibraryClientCache.get(libraryType) + + if (cached && cached.expiresAt > now) { + return cached.promise + } + + const promise = requestReferenceLibrary<{ items: IntakeReferenceLibraryItemDTO[] }>( `/api/intake/reference-library?type=${encodeURIComponent(libraryType)}`, - ) - return data.items + ).then((data) => data.items) + + referenceLibraryClientCache.set(libraryType, { + expiresAt: now + REFERENCE_LIBRARY_CLIENT_CACHE_TTL_MS, + promise, + }) + + promise.catch(() => { + if (referenceLibraryClientCache.get(libraryType)?.promise === promise) { + referenceLibraryClientCache.delete(libraryType) + } + }) + + return promise } export async function replaceIntakeReferenceLibraryItems( libraryType: IntakeReferenceLibraryType, items: IntakeReferenceLibraryClientItem[], ): Promise { + referenceLibraryClientCache.delete(libraryType) + const data = await requestReferenceLibrary<{ items: IntakeReferenceLibraryItemDTO[] }>( '/api/intake/reference-library', { @@ -160,6 +197,7 @@ export async function replaceIntakeReferenceLibraryItems( }), }, ) + referenceLibraryClientCache.delete(libraryType) return data.items } diff --git a/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts index 23004ade..353cb0af 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/intake-non-integrated-product.repository.test.ts @@ -2,6 +2,7 @@ jest.mock('@/lib/prisma', () => ({ prisma: { $transaction: jest.fn(), intakeNonIntegratedProduct: { + count: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), @@ -10,6 +11,14 @@ jest.mock('@/lib/prisma', () => ({ createMany: jest.fn(), deleteMany: jest.fn(), }, + intakeNonIntegratedPricingRow: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + intakeNonIntegratedProductLimitRow: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, }, })) @@ -19,6 +28,7 @@ import { IntakeNonIntegratedProductRepositoryClass } from '../intake-non-integra const mockPrisma = prisma as unknown as { $transaction: jest.Mock intakeNonIntegratedProduct: { + count: jest.Mock findMany: jest.Mock create: jest.Mock update: jest.Mock @@ -27,6 +37,14 @@ const mockPrisma = prisma as unknown as { createMany: jest.Mock deleteMany: jest.Mock } + intakeNonIntegratedPricingRow: { + createMany: jest.Mock + deleteMany: jest.Mock + } + intakeNonIntegratedProductLimitRow: { + createMany: jest.Mock + deleteMany: jest.Mock + } } describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { @@ -98,6 +116,35 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { expect(result).toBeNull() }) + it('lists active pages with parallel reads instead of wrapping read queries in a transaction', async () => { + const repository = new IntakeNonIntegratedProductRepositoryClass() + const records = [ + { + id: 'active-1', + spu: 'DNZ0001', + }, + ] + mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue(records) + mockPrisma.intakeNonIntegratedProduct.count.mockResolvedValue(42) + + const result = await repository.listActivePage({ + page: 2, + pageSize: 20, + }) + + expect(result).toEqual({ records, total: 42 }) + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 20, + }), + ) + expect(mockPrisma.intakeNonIntegratedProduct.count).toHaveBeenCalledWith({ + where: expect.any(Object), + }) + }) + it('creates structured SKU rows after creating the product record', async () => { const repository = new IntakeNonIntegratedProductRepositoryClass() mockPrisma.intakeNonIntegratedProduct.create.mockResolvedValue({ @@ -168,6 +215,14 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { callOrder.push('sku.createMany') return { count: 1 } }) + mockPrisma.intakeNonIntegratedPricingRow.deleteMany.mockImplementation(async () => { + callOrder.push('pricing.deleteMany') + return { count: 0 } + }) + mockPrisma.intakeNonIntegratedProductLimitRow.deleteMany.mockImplementation(async () => { + callOrder.push('productLimit.deleteMany') + return { count: 0 } + }) await repository.update('non-integrated-1', { payload: {}, @@ -188,7 +243,13 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { }, } as never) - expect(callOrder).toEqual(['product.update', 'sku.deleteMany', 'sku.createMany']) + expect(callOrder).toEqual([ + 'product.update', + 'sku.deleteMany', + 'pricing.deleteMany', + 'productLimit.deleteMany', + 'sku.createMany', + ]) expect(mockPrisma.intakeNonIntegratedProduct.update).toHaveBeenCalledWith({ where: { id: 'non-integrated-1' }, data: expect.not.objectContaining({ @@ -198,6 +259,12 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => { expect(mockPrisma.intakeNonIntegratedSkuItem.deleteMany).toHaveBeenCalledWith({ where: { productId: 'non-integrated-1' }, }) + expect(mockPrisma.intakeNonIntegratedPricingRow.deleteMany).toHaveBeenCalledWith({ + where: { productId: 'non-integrated-1' }, + }) + expect(mockPrisma.intakeNonIntegratedProductLimitRow.deleteMany).toHaveBeenCalledWith({ + where: { productId: 'non-integrated-1' }, + }) expect(mockPrisma.intakeNonIntegratedSkuItem.createMany).toHaveBeenCalledWith({ data: [ expect.objectContaining({ diff --git a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts index c57a9d73..8a62b2f7 100644 --- a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts +++ b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts @@ -336,34 +336,37 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< : activeWhere const skip = (params.page - 1) * params.pageSize - const [records, total] = await this.prisma.$transaction([ - this.prisma.intakeNonIntegratedProduct.findMany({ - where, - include: { - profile: true, - skuItems: { - orderBy: { - sortOrder: 'asc', - }, - }, - pricingRows: { - orderBy: { - skuItemId: 'asc', - }, - }, - productLimitRows: { - orderBy: { - skuItemId: 'asc', - }, + const recordsPromise = this.prisma.intakeNonIntegratedProduct.findMany({ + where, + include: { + profile: true, + skuItems: { + orderBy: { + sortOrder: 'asc', }, }, - orderBy: { - createdAt: 'desc', + pricingRows: { + orderBy: { + skuItemId: 'asc', + }, }, - skip, - take: params.pageSize, - }), - this.prisma.intakeNonIntegratedProduct.count({ where }), + productLimitRows: { + orderBy: { + skuItemId: 'asc', + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: params.pageSize, + }) + const totalPromise = this.prisma.intakeNonIntegratedProduct.count({ where }) + + const [records, total] = await Promise.all([ + recordsPromise, + totalPromise, ]) return { records, total } diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index ad69571b..b0c8e23b 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -407,6 +407,25 @@ describe('intakeNonIntegratedProductService', () => { ) }) + it('caps non-integrated list page size at 100 before hitting the repository', async () => { + mockedRepository.listActivePage.mockResolvedValue({ + records: [], + total: 0, + } as never) + + const result = await intakeNonIntegratedProductService.listPage(baseUser, { + page: 1, + pageSize: 1000, + }) + + expect(mockedRepository.listActivePage).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 100, + }), + ) + expect(result.pageInfo.pageSize).toBe(100) + }) + it('does not let external intake users read another user record by id', async () => { mockedRepository.findActiveById.mockResolvedValue( createRecord({ diff --git a/furniture-3d-system/src/server/services/__tests__/intake-reference-library.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-reference-library.service.test.ts index 8e6ad0c8..3c25d18f 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-reference-library.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-reference-library.service.test.ts @@ -1,5 +1,5 @@ import { prisma } from '@/lib/prisma' -import { intakeReferenceLibraryService } from '../intake-reference-library.service' +import { IntakeReferenceLibraryService } from '../intake-reference-library.service' jest.mock('@/lib/prisma', () => ({ prisma: { @@ -13,6 +13,7 @@ jest.mock('@/lib/prisma', () => ({ })) const mockedPrisma = jest.mocked(prisma) +let service: IntakeReferenceLibraryService function createLibraryRow(overrides: Record = {}) { return { @@ -37,6 +38,7 @@ function createLibraryRow(overrides: Record = {}) { describe('IntakeReferenceLibraryService', () => { beforeEach(() => { jest.clearAllMocks() + service = new IntakeReferenceLibraryService() mockedPrisma.$transaction.mockImplementation(async (callback) => callback({ intakeReferenceLibraryItem: { @@ -48,8 +50,68 @@ describe('IntakeReferenceLibraryService', () => { mockedPrisma.intakeReferenceLibraryItem.findMany.mockResolvedValue([createLibraryRow()] as never) }) + it('list 在短 TTL 内复用同类型查询,避免同页并发重复打数据库', async () => { + let resolveRows: (rows: ReturnType[]) => void = () => {} + mockedPrisma.intakeReferenceLibraryItem.findMany.mockReturnValueOnce( + new Promise((resolve) => { + resolveRows = resolve + }) as never, + ) + + const first = service.list('PACKAGING_MATERIAL') + const second = service.list('PACKAGING_MATERIAL') + + expect(mockedPrisma.intakeReferenceLibraryItem.findMany).toHaveBeenCalledTimes(1) + + resolveRows([createLibraryRow()]) + const [firstResult, secondResult] = await Promise.all([first, second]) + + expect(firstResult).toEqual(secondResult) + expect(firstResult[0]).toEqual(expect.objectContaining({ + libraryType: 'PACKAGING_MATERIAL', + code: 'A001', + })) + }) + + it('replaceAll 成功后立即失效同类型缓存并返回新列表', async () => { + mockedPrisma.intakeReferenceLibraryItem.findMany + .mockResolvedValueOnce([ + createLibraryRow({ + code: 'OLD', + itemKey: 'OLD', + name: '旧纸箱', + }), + ] as never) + .mockResolvedValueOnce([ + createLibraryRow({ + code: 'NEW', + itemKey: 'NEW', + name: '新纸箱', + }), + ] as never) + + await service.list('PACKAGING_MATERIAL') + const result = await service.replaceAll({ + libraryType: 'PACKAGING_MATERIAL', + userId: 'user-1', + items: [ + { + itemKey: 'NEW', + code: 'NEW', + name: '新纸箱', + }, + ], + }) + + expect(mockedPrisma.intakeReferenceLibraryItem.findMany).toHaveBeenCalledTimes(2) + expect(result[0]).toEqual(expect.objectContaining({ + itemKey: 'NEW', + code: 'NEW', + })) + }) + it('replaceAll 使用数据库事务保存资料库条目,而不是浏览器本地缓存', async () => { - const result = await intakeReferenceLibraryService.replaceAll({ + const result = await service.replaceAll({ libraryType: 'PACKAGING_MATERIAL', userId: 'user-1', items: [ @@ -105,7 +167,7 @@ describe('IntakeReferenceLibraryService', () => { it('replaceAll 拒绝同一资料库内重复编码,避免服务端继续制造脏数据', async () => { await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'ACCESSORY', items: [ { itemKey: 'screw-a', code: 'A001', name: '螺丝 A' }, @@ -119,7 +181,7 @@ describe('IntakeReferenceLibraryService', () => { it('replaceAll 拒绝用列表顺序兜底 itemKey,避免资料身份漂移', async () => { await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'ACCESSORY', items: [ { name: '缺少稳定身份的配件' }, @@ -132,7 +194,7 @@ describe('IntakeReferenceLibraryService', () => { it('replaceAll 不允许用数据库 id 兜底业务身份,避免前端临时 id 变成资料主键', async () => { await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'ACCESSORY', items: [ { id: 'temporary-client-id', name: '临时配件' }, @@ -145,7 +207,7 @@ describe('IntakeReferenceLibraryService', () => { it('replaceAll 拒绝只有 itemKey 但缺少 code 的资料行', async () => { await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'ACCESSORY', items: [ { itemKey: 'ACC001', name: '缺少编码的配件' }, @@ -157,7 +219,7 @@ describe('IntakeReferenceLibraryService', () => { }) it('replaceAll 缺少 itemKey 时只允许用稳定 code 兜底业务身份', async () => { - await intakeReferenceLibraryService.replaceAll({ + await service.replaceAll({ libraryType: 'ACCESSORY', userId: 'user-1', items: [ @@ -184,7 +246,7 @@ describe('IntakeReferenceLibraryService', () => { }) it('replaceAll 保存资料库时用结构化列覆盖 payload 里的旧身份字段', async () => { - await intakeReferenceLibraryService.replaceAll({ + await service.replaceAll({ libraryType: 'ACCESSORY', userId: 'user-1', items: [ @@ -231,7 +293,7 @@ describe('IntakeReferenceLibraryService', () => { it('SPU_RULE 必须有产品品类和有效号段前缀', async () => { await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'SPU_RULE', items: [ { itemKey: '餐桌', code: '12', name: '餐桌' }, @@ -240,7 +302,7 @@ describe('IntakeReferenceLibraryService', () => { ).rejects.toThrow('SPU 规则号段前缀无效') await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'SPU_RULE', items: [ { itemKey: '餐桌', code: 'CZ', name: '' }, @@ -253,7 +315,7 @@ describe('IntakeReferenceLibraryService', () => { it('SPU_RULE 保存时用结构化 code/name 规范化 payload,并拒绝重复产品品类', async () => { await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'SPU_RULE', items: [ { itemKey: '餐桌-a', code: 'CZA', name: '餐桌' }, @@ -265,7 +327,7 @@ describe('IntakeReferenceLibraryService', () => { expect(mockedPrisma.$transaction).not.toHaveBeenCalled() await expect( - intakeReferenceLibraryService.replaceAll({ + service.replaceAll({ libraryType: 'SPU_RULE', items: [ { itemKey: 'desk-a', code: 'DSA', name: 'Desk' }, @@ -276,7 +338,7 @@ describe('IntakeReferenceLibraryService', () => { expect(mockedPrisma.$transaction).not.toHaveBeenCalled() - await intakeReferenceLibraryService.replaceAll({ + await service.replaceAll({ libraryType: 'SPU_RULE', userId: 'user-1', items: [ diff --git a/furniture-3d-system/src/server/services/intake-reference-library.service.ts b/furniture-3d-system/src/server/services/intake-reference-library.service.ts index 29015d19..143d5632 100644 --- a/furniture-3d-system/src/server/services/intake-reference-library.service.ts +++ b/furniture-3d-system/src/server/services/intake-reference-library.service.ts @@ -17,6 +17,7 @@ const PAYLOAD_IDENTITY_MIRROR_LIBRARY_TYPES = new Set +} + function normalizeLibraryType(value: string): IntakeReferenceLibraryType { const normalized = value.trim().toUpperCase() if (!INTAKE_REFERENCE_LIBRARY_TYPES.includes(normalized as IntakeReferenceLibraryType)) { @@ -266,13 +272,34 @@ function toDTO(row: Awaited() + async list(libraryTypeInput: string): Promise { const libraryType = normalizeLibraryType(libraryTypeInput) - const rows = await prisma.intakeReferenceLibraryItem.findMany({ + const now = Date.now() + const cached = this.listCache.get(libraryType) + + if (cached && cached.expiresAt > now) { + return cached.promise + } + + const promise = prisma.intakeReferenceLibraryItem.findMany({ where: { libraryType }, orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }, { name: 'asc' }, { itemKey: 'asc' }], + }).then((rows) => rows.map(toDTO)) + + this.listCache.set(libraryType, { + expiresAt: now + LIST_CACHE_TTL_MS, + promise, }) - return rows.map(toDTO) + + promise.catch(() => { + if (this.listCache.get(libraryType)?.promise === promise) { + this.listCache.delete(libraryType) + } + }) + + return promise } async replaceAll(input: ReplaceIntakeReferenceLibraryInput): Promise { @@ -283,6 +310,8 @@ class IntakeReferenceLibraryService { const itemKeys = items.map((item) => item.itemKey) const userId = input.userId?.trim() || null + this.listCache.delete(libraryType) + await prisma.$transaction(async (tx) => { if (itemKeys.length > 0) { await tx.intakeReferenceLibraryItem.deleteMany({ @@ -332,6 +361,7 @@ class IntakeReferenceLibraryService { } }) + this.listCache.delete(libraryType) return this.list(libraryType) } } diff --git a/scripts/create-yydd-prod-deploy.ps1 b/scripts/create-yydd-prod-deploy.ps1 index d91a066d..3a0427bc 100644 --- a/scripts/create-yydd-prod-deploy.ps1 +++ b/scripts/create-yydd-prod-deploy.ps1 @@ -164,11 +164,33 @@ copy_legacy_env_if_needed() { exit 1 } +prepare_standalone_runtime() { + local app_root="$1" + local standalone_root="$app_root/.next/standalone" + + if [ ! -f "$standalone_root/server.js" ]; then + echo "Missing Next standalone server: $standalone_root/server.js" >&2 + exit 1 + fi + if [ ! -d "$app_root/.next/static" ]; then + echo "Missing Next static assets: $app_root/.next/static" >&2 + exit 1 + fi + + mkdir -p "$standalone_root/.next" + rm -rf -- "$standalone_root/.next/static" "$standalone_root/public" + cp -a "$app_root/.next/static" "$standalone_root/.next/static" + if [ -d "$app_root/public" ]; then + cp -a "$app_root/public" "$standalone_root/public" + fi +} + start_pm2_release() { local release_root="$1" local app_root="$release_root/__PROJECT_SUBDIR__" local start_command - start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT" + prepare_standalone_runtime "$app_root" + start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js" pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command" pm2 save >/dev/null diff --git a/scripts/deploy-yydd-prod-from-git.ps1 b/scripts/deploy-yydd-prod-from-git.ps1 index f33d088c..4977b54e 100644 --- a/scripts/deploy-yydd-prod-from-git.ps1 +++ b/scripts/deploy-yydd-prod-from-git.ps1 @@ -222,11 +222,33 @@ copy_legacy_env_if_needed() { exit 1 } +prepare_standalone_runtime() { + local app_root="$1" + local standalone_root="$app_root/.next/standalone" + + if [ ! -f "$standalone_root/server.js" ]; then + echo "Missing Next standalone server: $standalone_root/server.js" >&2 + exit 1 + fi + if [ ! -d "$app_root/.next/static" ]; then + echo "Missing Next static assets: $app_root/.next/static" >&2 + exit 1 + fi + + mkdir -p "$standalone_root/.next" + rm -rf -- "$standalone_root/.next/static" "$standalone_root/public" + cp -a "$app_root/.next/static" "$standalone_root/.next/static" + if [ -d "$app_root/public" ]; then + cp -a "$app_root/public" "$standalone_root/public" + fi +} + start_pm2_release() { local release_root="$1" local app_root="$release_root/$PROJECT_SUBDIR" local start_command - start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT" + prepare_standalone_runtime "$app_root" + start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js" pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command" pm2 save >/dev/null