fix: stabilize intake navigation requests

This commit is contained in:
WuYongJie 2026-06-16 15:08:06 +08:00
parent 6eb624f8e4
commit d374470151
11 changed files with 458 additions and 47 deletions

View File

@ -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({

View File

@ -19,6 +19,7 @@ type ApiEnvelope<T> = {
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<string, unknown> = { ...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))
})

View File

@ -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> = {}): 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<number, []>
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')
})
})

View File

@ -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<IntakeReferenceLibraryItemDTO[]>
}
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<IntakeReferenceLibraryItemDTO[]> {
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<IntakeReferenceLibraryItemDTO[]> {
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
}

View File

@ -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({

View File

@ -336,8 +336,7 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
: activeWhere
const skip = (params.page - 1) * params.pageSize
const [records, total] = await this.prisma.$transaction([
this.prisma.intakeNonIntegratedProduct.findMany({
const recordsPromise = this.prisma.intakeNonIntegratedProduct.findMany({
where,
include: {
profile: true,
@ -362,8 +361,12 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
},
skip,
take: params.pageSize,
}),
this.prisma.intakeNonIntegratedProduct.count({ where }),
})
const totalPromise = this.prisma.intakeNonIntegratedProduct.count({ where })
const [records, total] = await Promise.all([
recordsPromise,
totalPromise,
])
return { records, total }

View File

@ -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({

View File

@ -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<string, unknown> = {}) {
return {
@ -37,6 +38,7 @@ function createLibraryRow(overrides: Record<string, unknown> = {}) {
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<typeof createLibraryRow>[]) => 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: [

View File

@ -17,6 +17,7 @@ const PAYLOAD_IDENTITY_MIRROR_LIBRARY_TYPES = new Set<IntakeReferenceLibraryType
'ACCESSORY',
'ALPHABET_LIBRARY',
])
const LIST_CACHE_TTL_MS = 60_000
export type IntakeReferenceLibraryItemDTO = {
id: string
@ -53,6 +54,11 @@ type NormalizedReferenceLibraryItem = {
isActive: boolean
}
type ReferenceLibraryListCacheEntry = {
expiresAt: number
promise: Promise<IntakeReferenceLibraryItemDTO[]>
}
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<ReturnType<typeof prisma.intakeReferenceLibraryItem.
}
class IntakeReferenceLibraryService {
private readonly listCache = new Map<IntakeReferenceLibraryType, ReferenceLibraryListCacheEntry>()
async list(libraryTypeInput: string): Promise<IntakeReferenceLibraryItemDTO[]> {
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<IntakeReferenceLibraryItemDTO[]> {
@ -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)
}
}

View File

@ -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

View File

@ -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