fix: 避免signals异常回退并优化标签匹配查询
This commit is contained in:
@@ -145,6 +145,116 @@ function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount {
|
|||||||
})[0]!
|
})[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IncomingNormalizedTag = ReturnType<typeof normalizeIncomingTag>
|
||||||
|
|
||||||
|
type TagLookupMaps = {
|
||||||
|
tagByExactName: Map<string, TagWithProjectCount[]>
|
||||||
|
tagByExactNameEn: Map<string, TagWithProjectCount[]>
|
||||||
|
tagBySlug: Map<string, TagWithProjectCount>
|
||||||
|
tagByCanonicalName: Map<string, TagWithProjectCount[]>
|
||||||
|
tagByCanonicalNameEn: Map<string, TagWithProjectCount[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTagMapEntry(
|
||||||
|
map: Map<string, TagWithProjectCount[]>,
|
||||||
|
key: string,
|
||||||
|
tag: TagWithProjectCount
|
||||||
|
): void {
|
||||||
|
if (!key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = map.get(key)
|
||||||
|
if (current) {
|
||||||
|
current.push(tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map.set(key, [tag])
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTagLookupMaps(tags: TagWithProjectCount[]): TagLookupMaps {
|
||||||
|
const tagByExactName = new Map<string, TagWithProjectCount[]>()
|
||||||
|
const tagByExactNameEn = new Map<string, TagWithProjectCount[]>()
|
||||||
|
const tagBySlug = new Map<string, TagWithProjectCount>()
|
||||||
|
const tagByCanonicalName = new Map<string, TagWithProjectCount[]>()
|
||||||
|
const tagByCanonicalNameEn = new Map<string, TagWithProjectCount[]>()
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const exactNameKey = normalizeWhitespace(tag.name)
|
||||||
|
const exactNameEnKey = normalizeWhitespace(tag.nameEn || '')
|
||||||
|
const canonicalNameKey = canonicalizeTagKey(tag.name)
|
||||||
|
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || '')
|
||||||
|
|
||||||
|
pushTagMapEntry(tagByExactName, exactNameKey, tag)
|
||||||
|
pushTagMapEntry(tagByExactNameEn, exactNameEnKey, tag)
|
||||||
|
pushTagMapEntry(tagByCanonicalName, canonicalNameKey, tag)
|
||||||
|
pushTagMapEntry(tagByCanonicalNameEn, canonicalNameEnKey, tag)
|
||||||
|
tagBySlug.set(tag.slug, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagByExactName,
|
||||||
|
tagByExactNameEn,
|
||||||
|
tagBySlug,
|
||||||
|
tagByCanonicalName,
|
||||||
|
tagByCanonicalNameEn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagToLookupMaps(lookups: TagLookupMaps, tag: TagWithProjectCount): void {
|
||||||
|
const exactNameKey = normalizeWhitespace(tag.name)
|
||||||
|
const exactNameEnKey = normalizeWhitespace(tag.nameEn || '')
|
||||||
|
const canonicalNameKey = canonicalizeTagKey(tag.name)
|
||||||
|
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || '')
|
||||||
|
|
||||||
|
pushTagMapEntry(lookups.tagByExactName, exactNameKey, tag)
|
||||||
|
pushTagMapEntry(lookups.tagByExactNameEn, exactNameEnKey, tag)
|
||||||
|
pushTagMapEntry(lookups.tagByCanonicalName, canonicalNameKey, tag)
|
||||||
|
pushTagMapEntry(lookups.tagByCanonicalNameEn, canonicalNameEnKey, tag)
|
||||||
|
lookups.tagBySlug.set(tag.slug, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCandidateTags(incomingTags: IncomingNormalizedTag[]): Promise<TagWithProjectCount[]> {
|
||||||
|
const nameValues = Array.from(
|
||||||
|
new Set(incomingTags.map((tag) => normalizeWhitespace(tag.name)).filter((name) => name.length > 0))
|
||||||
|
)
|
||||||
|
const slugValues = Array.from(
|
||||||
|
new Set(incomingTags.map((tag) => tag.slug).filter((slug) => slug.length > 0))
|
||||||
|
)
|
||||||
|
const nameEnValues = Array.from(
|
||||||
|
new Set(
|
||||||
|
incomingTags
|
||||||
|
.map((tag) => normalizeWhitespace(tag.nameEn || ''))
|
||||||
|
.filter((nameEn) => nameEn.length > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const whereOr: Prisma.TagWhereInput[] = []
|
||||||
|
if (nameValues.length > 0) {
|
||||||
|
whereOr.push({ name: { in: nameValues } })
|
||||||
|
}
|
||||||
|
if (slugValues.length > 0) {
|
||||||
|
whereOr.push({ slug: { in: slugValues } })
|
||||||
|
}
|
||||||
|
if (nameEnValues.length > 0) {
|
||||||
|
whereOr.push({ nameEn: { in: nameEnValues } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whereOr.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.tag.findMany({
|
||||||
|
where: {
|
||||||
|
OR: whereOr,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { projects: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
||||||
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn)
|
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn)
|
||||||
const fallbackCategory = inferTagCategory({
|
const fallbackCategory = inferTagCategory({
|
||||||
@@ -333,68 +443,41 @@ export async function upsertTags(tags: ProjectInput['tags']) {
|
|||||||
return [await getFallbackTag()]
|
return [await getFallbackTag()]
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingTags = await prisma.tag.findMany({
|
const candidateTags = await getCandidateTags(normalizedIncomingTags)
|
||||||
include: {
|
const lookups = createTagLookupMaps(candidateTags)
|
||||||
_count: {
|
|
||||||
select: { projects: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const tagByExactName = new Map<string, TagWithProjectCount[]>()
|
|
||||||
const tagBySlug = new Map<string, TagWithProjectCount>()
|
|
||||||
const tagByCanonicalName = new Map<string, TagWithProjectCount[]>()
|
|
||||||
const tagByCanonicalNameEn = new Map<string, TagWithProjectCount[]>()
|
|
||||||
|
|
||||||
for (const tag of existingTags) {
|
|
||||||
const exactNameKey = normalizeWhitespace(tag.name)
|
|
||||||
const existingCanonicalName = canonicalizeTagKey(tag.name)
|
|
||||||
const existingCanonicalNameEn = canonicalizeTagKey(tag.nameEn || '')
|
|
||||||
|
|
||||||
if (!tagByExactName.has(exactNameKey)) {
|
|
||||||
tagByExactName.set(exactNameKey, [])
|
|
||||||
}
|
|
||||||
tagByExactName.get(exactNameKey)!.push(tag)
|
|
||||||
|
|
||||||
if (existingCanonicalName) {
|
|
||||||
if (!tagByCanonicalName.has(existingCanonicalName)) {
|
|
||||||
tagByCanonicalName.set(existingCanonicalName, [])
|
|
||||||
}
|
|
||||||
tagByCanonicalName.get(existingCanonicalName)!.push(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingCanonicalNameEn) {
|
|
||||||
if (!tagByCanonicalNameEn.has(existingCanonicalNameEn)) {
|
|
||||||
tagByCanonicalNameEn.set(existingCanonicalNameEn, [])
|
|
||||||
}
|
|
||||||
tagByCanonicalNameEn.get(existingCanonicalNameEn)!.push(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
tagBySlug.set(tag.slug, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTags: TagWithProjectCount[] = []
|
const resolvedTags: TagWithProjectCount[] = []
|
||||||
|
|
||||||
for (const incomingTag of normalizedIncomingTags) {
|
for (const incomingTag of normalizedIncomingTags) {
|
||||||
const exactNameCandidates = tagByExactName.get(incomingTag.name) || []
|
const exactNameCandidates = lookups.tagByExactName.get(incomingTag.name) || []
|
||||||
let matchedTag = exactNameCandidates.length > 0 ? chooseBestTag(exactNameCandidates) : null
|
let matchedTag = exactNameCandidates.length > 0 ? chooseBestTag(exactNameCandidates) : null
|
||||||
|
|
||||||
|
if (!matchedTag && incomingTag.nameEn) {
|
||||||
|
const exactNameEnCandidates =
|
||||||
|
lookups.tagByExactNameEn.get(incomingTag.nameEn) || []
|
||||||
|
if (exactNameEnCandidates.length > 0) {
|
||||||
|
matchedTag = chooseBestTag(exactNameEnCandidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!matchedTag && incomingTag.canonicalNameKey) {
|
if (!matchedTag && incomingTag.canonicalNameKey) {
|
||||||
const canonicalNameCandidates = tagByCanonicalName.get(incomingTag.canonicalNameKey) || []
|
const canonicalNameCandidates =
|
||||||
|
lookups.tagByCanonicalName.get(incomingTag.canonicalNameKey) || []
|
||||||
if (canonicalNameCandidates.length > 0) {
|
if (canonicalNameCandidates.length > 0) {
|
||||||
matchedTag = chooseBestTag(canonicalNameCandidates)
|
matchedTag = chooseBestTag(canonicalNameCandidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchedTag && incomingTag.canonicalNameEnKey) {
|
if (!matchedTag && incomingTag.canonicalNameEnKey) {
|
||||||
const canonicalNameEnCandidates = tagByCanonicalNameEn.get(incomingTag.canonicalNameEnKey) || []
|
const canonicalNameEnCandidates =
|
||||||
|
lookups.tagByCanonicalNameEn.get(incomingTag.canonicalNameEnKey) || []
|
||||||
if (canonicalNameEnCandidates.length > 0) {
|
if (canonicalNameEnCandidates.length > 0) {
|
||||||
matchedTag = chooseBestTag(canonicalNameEnCandidates)
|
matchedTag = chooseBestTag(canonicalNameEnCandidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchedTag) {
|
if (!matchedTag) {
|
||||||
matchedTag = tagBySlug.get(incomingTag.slug) || null
|
matchedTag = lookups.tagBySlug.get(incomingTag.slug) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedTag) {
|
if (matchedTag) {
|
||||||
@@ -422,6 +505,7 @@ export async function upsertTags(tags: ProjectInput['tags']) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
matchedTag = updatedTag
|
matchedTag = updatedTag
|
||||||
|
addTagToLookupMaps(lookups, matchedTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedTags.push(matchedTag)
|
resolvedTags.push(matchedTag)
|
||||||
@@ -449,21 +533,7 @@ export async function upsertTags(tags: ProjectInput['tags']) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
resolvedTags.push(createdTag)
|
resolvedTags.push(createdTag)
|
||||||
|
addTagToLookupMaps(lookups, createdTag)
|
||||||
if (!tagByExactName.has(createdTag.name)) {
|
|
||||||
tagByExactName.set(createdTag.name, [])
|
|
||||||
}
|
|
||||||
tagByExactName.get(createdTag.name)!.push(createdTag)
|
|
||||||
tagBySlug.set(createdTag.slug, createdTag)
|
|
||||||
if (createdTag.nameEn) {
|
|
||||||
const canonicalNameEn = canonicalizeTagKey(createdTag.nameEn)
|
|
||||||
if (canonicalNameEn) {
|
|
||||||
if (!tagByCanonicalNameEn.has(canonicalNameEn)) {
|
|
||||||
tagByCanonicalNameEn.set(canonicalNameEn, [])
|
|
||||||
}
|
|
||||||
tagByCanonicalNameEn.get(canonicalNameEn)!.push(createdTag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
const fallbackTag = await prisma.tag.findFirst({
|
const fallbackTag = await prisma.tag.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { ZodError } from 'zod'
|
import { ZodError } from 'zod'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
import {
|
||||||
|
computeSignalHotness,
|
||||||
|
isSignalHotColumnMissingError,
|
||||||
|
markSignalHotColumnsUnsupported,
|
||||||
|
supportsSignalHotColumns,
|
||||||
|
} from '@/lib/signal-hotness'
|
||||||
import {
|
import {
|
||||||
SignalQuerySchema,
|
SignalQuerySchema,
|
||||||
type SignalQuery,
|
type SignalQuery,
|
||||||
@@ -34,6 +39,24 @@ interface SignalView {
|
|||||||
|
|
||||||
const VALID_SECTION_STYLES: SignalSectionStyle[] = ['focus', 'debate', 'evidence', 'action', 'risk']
|
const VALID_SECTION_STYLES: SignalSectionStyle[] = ['focus', 'debate', 'evidence', 'action', 'risk']
|
||||||
|
|
||||||
|
type SignalQueryRow = {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
sourceUrl: string
|
||||||
|
title: string
|
||||||
|
titleEn: string | null
|
||||||
|
summary: string
|
||||||
|
summaryEn: string | null
|
||||||
|
topic: string | null
|
||||||
|
topicEn: string | null
|
||||||
|
tags: Prisma.JsonValue
|
||||||
|
sections: Prisma.JsonValue
|
||||||
|
engagement: number
|
||||||
|
hotScore?: number | null
|
||||||
|
isHot?: boolean | null
|
||||||
|
publishedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
function isSignalSource(value: string): value is SignalSource {
|
function isSignalSource(value: string): value is SignalSource {
|
||||||
return (
|
return (
|
||||||
value === 'hacker_news' ||
|
value === 'hacker_news' ||
|
||||||
@@ -180,6 +203,28 @@ function toSignalView(row: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIGNAL_BASE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
source: true,
|
||||||
|
sourceUrl: true,
|
||||||
|
title: true,
|
||||||
|
titleEn: true,
|
||||||
|
summary: true,
|
||||||
|
summaryEn: true,
|
||||||
|
topic: true,
|
||||||
|
topicEn: true,
|
||||||
|
tags: true,
|
||||||
|
sections: true,
|
||||||
|
engagement: true,
|
||||||
|
publishedAt: true,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SIGNAL_SELECT_WITH_HOT = {
|
||||||
|
...SIGNAL_BASE_SELECT,
|
||||||
|
hotScore: true,
|
||||||
|
isHot: true,
|
||||||
|
} as const
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -227,83 +272,50 @@ export async function GET(request: NextRequest) {
|
|||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
|
const hasHotColumns = await supportsSignalHotColumns(prisma)
|
||||||
|
const orderByWithHot: Prisma.SignalOrderByWithRelationInput[] =
|
||||||
parsed.sort === 'hot'
|
parsed.sort === 'hot'
|
||||||
? [{ isHot: 'desc' }, { hotScore: 'desc' }, { engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
? [{ isHot: 'desc' }, { hotScore: 'desc' }, { engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
|
const orderByFallback: Prisma.SignalOrderByWithRelationInput[] =
|
||||||
|
parsed.sort === 'hot'
|
||||||
|
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
|
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||||
|
|
||||||
let rows: Array<{
|
let rows: SignalQueryRow[]
|
||||||
id: string
|
|
||||||
source: string
|
|
||||||
sourceUrl: string
|
|
||||||
title: string
|
|
||||||
titleEn: string | null
|
|
||||||
summary: string
|
|
||||||
summaryEn: string | null
|
|
||||||
topic: string | null
|
|
||||||
topicEn: string | null
|
|
||||||
tags: Prisma.JsonValue
|
|
||||||
sections: Prisma.JsonValue
|
|
||||||
engagement: number
|
|
||||||
hotScore?: number | null
|
|
||||||
isHot?: boolean | null
|
|
||||||
publishedAt: Date
|
|
||||||
}>
|
|
||||||
|
|
||||||
try {
|
if (hasHotColumns) {
|
||||||
rows = await prisma.signal.findMany({
|
try {
|
||||||
where,
|
rows = await prisma.signal.findMany({
|
||||||
orderBy,
|
where,
|
||||||
take: parsed.limit + 1,
|
orderBy: orderByWithHot,
|
||||||
cursor: parsed.cursor ? { id: parsed.cursor } : undefined,
|
take: parsed.limit + 1,
|
||||||
skip: parsed.cursor ? 1 : undefined,
|
cursor: parsed.cursor ? { id: parsed.cursor } : undefined,
|
||||||
select: {
|
skip: parsed.cursor ? 1 : undefined,
|
||||||
id: true,
|
select: SIGNAL_SELECT_WITH_HOT,
|
||||||
source: true,
|
})
|
||||||
sourceUrl: true,
|
} catch (error) {
|
||||||
title: true,
|
if (!isSignalHotColumnMissingError(error)) {
|
||||||
titleEn: true,
|
throw error
|
||||||
summary: true,
|
}
|
||||||
summaryEn: true,
|
markSignalHotColumnsUnsupported()
|
||||||
topic: true,
|
rows = await prisma.signal.findMany({
|
||||||
topicEn: true,
|
where,
|
||||||
tags: true,
|
orderBy: orderByFallback,
|
||||||
sections: true,
|
take: parsed.limit + 1,
|
||||||
engagement: true,
|
cursor: parsed.cursor ? { id: parsed.cursor } : undefined,
|
||||||
hotScore: true,
|
skip: parsed.cursor ? 1 : undefined,
|
||||||
isHot: true,
|
select: SIGNAL_BASE_SELECT,
|
||||||
publishedAt: true,
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (!isSignalHotColumnMissingError(error)) {
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
const fallbackOrderBy: Prisma.SignalOrderByWithRelationInput[] =
|
|
||||||
parsed.sort === 'hot' ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] : [{ publishedAt: 'desc' }, { id: 'desc' }]
|
|
||||||
|
|
||||||
rows = await prisma.signal.findMany({
|
rows = await prisma.signal.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: fallbackOrderBy,
|
orderBy: orderByFallback,
|
||||||
take: parsed.limit + 1,
|
take: parsed.limit + 1,
|
||||||
cursor: parsed.cursor ? { id: parsed.cursor } : undefined,
|
cursor: parsed.cursor ? { id: parsed.cursor } : undefined,
|
||||||
skip: parsed.cursor ? 1 : undefined,
|
skip: parsed.cursor ? 1 : undefined,
|
||||||
select: {
|
select: SIGNAL_BASE_SELECT,
|
||||||
id: true,
|
|
||||||
source: true,
|
|
||||||
sourceUrl: true,
|
|
||||||
title: true,
|
|
||||||
titleEn: true,
|
|
||||||
summary: true,
|
|
||||||
summaryEn: true,
|
|
||||||
topic: true,
|
|
||||||
topicEn: true,
|
|
||||||
tags: true,
|
|
||||||
sections: true,
|
|
||||||
engagement: true,
|
|
||||||
publishedAt: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { isValidApiKey } from '@/lib/auth'
|
import { isValidApiKey } from '@/lib/auth'
|
||||||
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
import {
|
||||||
|
computeSignalHotness,
|
||||||
|
isSignalHotColumnMissingError,
|
||||||
|
markSignalHotColumnsUnsupported,
|
||||||
|
supportsSignalHotColumns,
|
||||||
|
} from '@/lib/signal-hotness'
|
||||||
import {
|
import {
|
||||||
SignalIngestionInputSchema,
|
SignalIngestionInputSchema,
|
||||||
SignalWebhookPayloadSchema,
|
SignalWebhookPayloadSchema,
|
||||||
@@ -67,6 +72,7 @@ export async function POST(request: NextRequest) {
|
|||||||
message: string
|
message: string
|
||||||
}>,
|
}>,
|
||||||
}
|
}
|
||||||
|
let hasHotColumns = await supportsSignalHotColumns(prisma)
|
||||||
|
|
||||||
for (let i = 0; i < payload.signals.length; i++) {
|
for (let i = 0; i < payload.signals.length; i++) {
|
||||||
const signalData = payload.signals[i]
|
const signalData = payload.signals[i]
|
||||||
@@ -117,37 +123,49 @@ export async function POST(request: NextRequest) {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
await prisma.signal.upsert({
|
const upsertWhere = {
|
||||||
where: {
|
source_sourceUrl: {
|
||||||
source_sourceUrl: {
|
|
||||||
source: validSignal.source,
|
|
||||||
sourceUrl: validSignal.sourceUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...baseData,
|
|
||||||
hotScore,
|
|
||||||
isHot,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
source: validSignal.source,
|
source: validSignal.source,
|
||||||
sourceUrl: validSignal.sourceUrl,
|
sourceUrl: validSignal.sourceUrl,
|
||||||
...baseData,
|
|
||||||
hotScore,
|
|
||||||
isHot,
|
|
||||||
},
|
},
|
||||||
}).catch(async (error) => {
|
}
|
||||||
if (!isSignalHotColumnMissingError(error)) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.signal.upsert({
|
if (hasHotColumns) {
|
||||||
where: {
|
try {
|
||||||
source_sourceUrl: {
|
await prisma.signal.upsert({
|
||||||
|
where: upsertWhere,
|
||||||
|
update: {
|
||||||
|
...baseData,
|
||||||
|
hotScore,
|
||||||
|
isHot,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
source: validSignal.source,
|
source: validSignal.source,
|
||||||
sourceUrl: validSignal.sourceUrl,
|
sourceUrl: validSignal.sourceUrl,
|
||||||
|
...baseData,
|
||||||
|
hotScore,
|
||||||
|
isHot,
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!isSignalHotColumnMissingError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
markSignalHotColumnsUnsupported()
|
||||||
|
hasHotColumns = false
|
||||||
|
await prisma.signal.upsert({
|
||||||
|
where: upsertWhere,
|
||||||
|
update: baseData,
|
||||||
|
create: {
|
||||||
|
source: validSignal.source,
|
||||||
|
sourceUrl: validSignal.sourceUrl,
|
||||||
|
...baseData,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await prisma.signal.upsert({
|
||||||
|
where: upsertWhere,
|
||||||
update: baseData,
|
update: baseData,
|
||||||
create: {
|
create: {
|
||||||
source: validSignal.source,
|
source: validSignal.source,
|
||||||
@@ -155,7 +173,7 @@ export async function POST(request: NextRequest) {
|
|||||||
...baseData,
|
...baseData,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
results.updated++
|
results.updated++
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SignalSource } from '@/lib/validations'
|
import type { SignalSource } from '@/lib/validations'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma, type PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
interface HotnessInput {
|
interface HotnessInput {
|
||||||
source: SignalSource
|
source: SignalSource
|
||||||
@@ -84,3 +84,61 @@ export function isSignalHotColumnMissingError(error: unknown): boolean {
|
|||||||
const column = String(error.meta?.column || '')
|
const column = String(error.meta?.column || '')
|
||||||
return column.includes('hotScore') || column.includes('isHot')
|
return column.includes('hotScore') || column.includes('isHot')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIGNAL_HOT_COLUMN_SUPPORT_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
let signalHotColumnSupportCache: { value: boolean; checkedAt: number } | null = null
|
||||||
|
let signalHotColumnSupportPending: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
export function markSignalHotColumnsUnsupported(): void {
|
||||||
|
signalHotColumnSupportCache = {
|
||||||
|
value: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function supportsSignalHotColumns(
|
||||||
|
prisma: PrismaClient
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (
|
||||||
|
signalHotColumnSupportCache &&
|
||||||
|
now - signalHotColumnSupportCache.checkedAt <= SIGNAL_HOT_COLUMN_SUPPORT_TTL_MS
|
||||||
|
) {
|
||||||
|
return signalHotColumnSupportCache.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signalHotColumnSupportPending) {
|
||||||
|
return signalHotColumnSupportPending
|
||||||
|
}
|
||||||
|
|
||||||
|
signalHotColumnSupportPending = prisma
|
||||||
|
.$queryRaw<Array<{ column_name: string }>>(Prisma.sql`
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'signals'
|
||||||
|
AND column_name IN ('hotScore', 'isHot')
|
||||||
|
`)
|
||||||
|
.then((rows) => {
|
||||||
|
const columns = new Set(rows.map((row) => row.column_name))
|
||||||
|
const value = columns.has('hotScore') && columns.has('isHot')
|
||||||
|
signalHotColumnSupportCache = {
|
||||||
|
value,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
signalHotColumnSupportCache = {
|
||||||
|
value: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
signalHotColumnSupportPending = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return signalHotColumnSupportPending
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user