diff --git a/.specstory/history/2025-06-26_16-50-改成-neondatabase.md b/.specstory/history/2025-06-26_16-50-改成-neondatabase.md new file mode 100644 index 00000000..01769c64 --- /dev/null +++ b/.specstory/history/2025-06-26_16-50-改成-neondatabase.md @@ -0,0 +1,122 @@ + + +# 改成 neondatabase (2025-06-27 00:50:50) + +_**User**_ + +改成 neondatabase + +--- + +_**Assistant**_ + + + +--- + +Okay, I see that the current database connection logic in `apps/ssr/src/lib/db.ts` is not quite right and has some errors. I'll fix it to correctly use `neondatabase`. + +Here's what I'll do: +1. I'll correct the import to use `neon` from `@neondatabase/serverless` and also import the `NeonQueryFunction` type for type safety. +2. I will fix the `createDrizzle` helper function to accept the database client as an argument. +3. I'll update the `DbManager` class to properly manage the database connection state, including the client and drizzle instances. +4. The `connect` method will be adjusted to correctly establish a connection using `neon` and initialize the drizzle instance. +5. Finally, `getDb` and `getPool` methods will be updated to ensure the database is connected and ready before returning the instance or pool. + +I will now apply these changes. + +--- + +```diff + import { env } from '@env' +- import { drizzle } from 'drizzle-orm/neon-http'; +- import { neon } from '@neondatabase/serverless'; ++ import { drizzle } from 'drizzle-orm/neon-http' ++ import { neon, type NeonQueryFunction } from '@neondatabase/serverless' + + import * as schema from '../schemas' + +- const createDrizzle = () => drizzle({ client: sql, schema, logger: true }) ++ const createDrizzle = (client: NeonQueryFunction) => ++ drizzle({ client, schema, logger: true }) + + export class DbManager { +- private drizzle: ReturnType ++ private drizzle: ReturnType | undefined + private ready = false ++ private client: NeonQueryFunction | undefined + + static shared = new DbManager() +``` + +```diff + } + +- const sql = postgres(env.PG_CONNECTION_STRING, { ++ const sql = neon(env.PG_CONNECTION_STRING!, { + prepare: false, + }) +- this.ready = true + this.client = sql + + await sql`select 1` + + this.drizzle = createDrizzle(sql) ++ this.ready = true + } + +``` + +```diff + } + +- if (!this.isReady()) { ++ if (!this.isReady() || !this.drizzle) { + throw new Error('Database is not ready') + } +``` + +```diff + throw new Error('Database is not enabled') + } ++ ++ if (!this.isReady() || !this.client) { ++ throw new Error('Database is not ready') ++ } + + return this.client +``` + +--- + + + +--- + +I see there's a linter error. It seems the `prepare` option is not expected in the `neon` function call within this context. The most common usage of the `neon` function from `@neondatabase/serverless` doesn't require a second argument for basic setup, and the default behavior is what's needed here. + +I will correct this by removing the options object from the `neon()` call in `apps/ssr/src/lib/db.ts`. + +--- + +```diff + } + +- const sql = neon(env.PG_CONNECTION_STRING!, { +- prepare: false, +- }) ++ const sql = neon(env.PG_CONNECTION_STRING!) + this.client = sql + +``` + +--- + + + +--- + +I've updated the database connection logic to use `neondatabase` and resolved the linter errors. Everything should be working correctly now. Let me know if there is anything else I can help with. + +--- + diff --git a/.specstory/history/2025-06-26_17-13-实现原子性upsert操作.md b/.specstory/history/2025-06-26_17-13-实现原子性upsert操作.md new file mode 100644 index 00000000..50376b87 --- /dev/null +++ b/.specstory/history/2025-06-26_17-13-实现原子性upsert操作.md @@ -0,0 +1,72 @@ + + +# 实现原子性upsert操作 (2025-06-27 01:13:17) + +_**User**_ + +这里需要 upsert, 如果没有就插入,有就更新。需要原子性 + +--- + +_**Assistant**_ + +我来帮你修改这个API,使其支持原子性的upsert操作。让我先查看一下views表的schema,然后实现原子性的upsert逻辑。 + +--- + +Read file: apps/ssr/src/schemas/view.ts + +--- + +现在我了解了views表的结构。我需要实现一个原子性的upsert操作,当refKey存在时更新views计数,不存在时插入新记录。在PostgreSQL中,我们可以使用`ON CONFLICT`子句来实现这个功能。 + +--- + +```diff + import { ViewDto } from './dto' + ++ export const runtime = 'edge' + export const POST = guardDbEnabled(async (req: NextRequest) => { + const { refKey } = ViewDto.parse(await req.json()) + + const db = DbManager.shared.getDb() + await db +- .update(views) +- .set({ ++ .insert(views) ++ .values({ ++ refKey, ++ views: 1, ++ }) ++ .onConflictDoUpdate({ ++ target: views.refKey, ++ set: { + views: sql`${views.views} + 1`, ++ }, + }) +- .where(eq(views.refKey, refKey)) + }) + +``` + +--- + +完美!我已经修改了代码,现在使用原子性的upsert操作: + +## 修改说明 + +1. **原子性操作**:使用`insert().onConflictDoUpdate()`替代了原来的`update().where()`,这是一个原子性操作 +2. **upsert逻辑**: + - 如果`refKey`不存在,插入新记录,`views`初始值为1 + - 如果`refKey`已存在(冲突),则更新现有记录,将`views`字段加1 +3. **冲突处理**:使用`target: views.refKey`指定冲突检测字段(因为refKey有unique约束) + +这样的实现确保了: +- **原子性**:整个操作在数据库层面是原子的,不会出现竞态条件 +- **性能**:只需要一次数据库操作,比先查询再决定插入或更新要高效 +- **正确性**:无论是新的refKey还是已存在的refKey,都能正确处理 + +现在这个API可以安全地处理并发请求,不会出现数据不一致的问题。 + +--- + diff --git a/apps/ssr/src/app/api/reactions/route.ts b/apps/ssr/src/app/api/reactions/route.ts index d6dbfe04..059c9d67 100644 --- a/apps/ssr/src/app/api/reactions/route.ts +++ b/apps/ssr/src/app/api/reactions/route.ts @@ -6,18 +6,16 @@ import { reactions } from '~/schemas' import { ReactionDto } from './dto' -export const runtime = 'edge' export const POST = guardDbEnabled(async (req: NextRequest) => { const { refKey, reaction } = ReactionDto.parse(await req.json()) const db = DbManager.shared.getDb() try { - const data = await db.query.reactions.findFirst() - console.info(data) await db.insert(reactions).values({ refKey, reaction, }) + return new Response('', { status: 201 }) } catch (error) { console.error('Failed to add reaction:', error) return new Response('Failed to add reaction', { status: 500 }) diff --git a/apps/ssr/src/app/api/views/route.ts b/apps/ssr/src/app/api/views/route.ts index ae090996..522d648f 100644 --- a/apps/ssr/src/app/api/views/route.ts +++ b/apps/ssr/src/app/api/views/route.ts @@ -1,4 +1,4 @@ -import { eq, sql } from 'drizzle-orm' +import { sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { guardDbEnabled } from '~/lib/api-guard' @@ -7,15 +7,21 @@ import { views } from '~/schemas' import { DbManager } from '../../../lib/db' import { ViewDto } from './dto' -export const runtime = 'edge' export const POST = guardDbEnabled(async (req: NextRequest) => { const { refKey } = ViewDto.parse(await req.json()) const db = DbManager.shared.getDb() await db - .update(views) - .set({ - views: sql`${views.views} + 1`, + .insert(views) + .values({ + refKey, + views: 1, }) - .where(eq(views.refKey, refKey)) + .onConflictDoUpdate({ + target: views.refKey, + set: { + views: sql`${views.views} + 1`, + }, + }) + return new Response('', { status: 201 }) }) diff --git a/apps/web/package.json b/apps/web/package.json index ab6cc818..85913ace 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -84,6 +84,7 @@ "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "@vitejs/plugin-react": "^4.6.0", + "ast-kit": "2.1.0", "babel-plugin-react-compiler": "19.1.0-rc.2", "code-inspector-plugin": "0.20.12", "daisyui": "5.0.43", @@ -99,6 +100,7 @@ "tailwindcss-animate": "1.0.7", "tailwindcss-safe-area": "0.6.0", "tailwindcss-uikit-colors": "1.0.0-alpha.1", + "unplugin-ast": "0.15.0", "vite-plugin-html": "3.2.2" } } \ No newline at end of file diff --git a/apps/web/plugins/vite/ast.ts b/apps/web/plugins/vite/ast.ts new file mode 100644 index 00000000..a9978bf5 --- /dev/null +++ b/apps/web/plugins/vite/ast.ts @@ -0,0 +1,35 @@ +import { isTaggedFunctionCallOf } from 'ast-kit' +import type { Transformer } from 'unplugin-ast' +import AST from 'unplugin-ast/vite' + +// Custom transformer for tw function that compresses template strings +const TwTransformer: Transformer = { + onNode: (node) => isTaggedFunctionCallOf(node, ['tw']), + transform(node) { + if (node.type === 'TaggedTemplateExpression') { + const { quasi } = node + + // Process template literals + if (quasi.type === 'TemplateLiteral') { + // Get the raw string content + const rawString = quasi.quasis[0]?.value?.raw || '' + + // Compress the string: remove extra whitespace, newlines, and normalize spaces + const compressedString = rawString + .replaceAll(/\s+/g, ' ') // Replace multiple whitespace with single space + .trim() // Remove leading and trailing whitespace + + // Update the template literal + quasi.quasis[0].value.raw = compressedString + quasi.quasis[0].value.cooked = compressedString + } + + return quasi + } + return node.arguments[0] + }, +} + +export const astPlugin = AST({ + transformer: [TwTransformer], +}) diff --git a/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx b/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx index ce0440ba..a3a4feeb 100644 --- a/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx +++ b/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx @@ -15,6 +15,7 @@ import { } from 'react' import { Blurhash } from 'react-blurhash' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import type { Swiper as SwiperType } from 'swiper' import { Keyboard, Navigation, Virtual } from 'swiper/modules' import { Swiper, SwiperSlide } from 'swiper/react' @@ -244,11 +245,12 @@ export const PhotoViewer = ({ {!isMobile && injectConfig.useApi && ( { - client.actReaction({ + onReaction={async (reaction) => { + await client.actReaction({ refKey: currentPhoto.id, reaction, }) + toast.success(t('photo.reaction.success')) }} /> )} diff --git a/apps/web/src/components/ui/photo-viewer/Reaction.tsx b/apps/web/src/components/ui/photo-viewer/Reaction.tsx index e842befa..b971c21b 100644 --- a/apps/web/src/components/ui/photo-viewer/Reaction.tsx +++ b/apps/web/src/components/ui/photo-viewer/Reaction.tsx @@ -95,6 +95,11 @@ export const ReactionButton = ({ @@ -149,13 +154,12 @@ export const ReactionButton = ({ diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index defb68c7..4c2c5055 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -3,7 +3,84 @@ import { Toaster as Sonner } from 'sonner' type ToasterProps = React.ComponentProps const Toaster = ({ ...props }: ToasterProps) => ( - + , + error: , + warning: , + info: , + loading: , + }} + {...props} + /> ) export { Toaster } diff --git a/apps/web/src/global.d.ts b/apps/web/src/global.d.ts index 03137544..75d87099 100644 --- a/apps/web/src/global.d.ts +++ b/apps/web/src/global.d.ts @@ -33,6 +33,10 @@ declare global { const __MANIFEST__: AfilmoryManifest const __CONFIG__: InjectConfig + /** + * This function is a macro, will replace in the build stage. + */ + export function tw(strings: TemplateStringsArray, ...values: any[]): string } export {} diff --git a/apps/web/src/hooks/usePhotoViewer.ts b/apps/web/src/hooks/usePhotoViewer.ts index d714e125..a9886fad 100644 --- a/apps/web/src/hooks/usePhotoViewer.ts +++ b/apps/web/src/hooks/usePhotoViewer.ts @@ -3,6 +3,7 @@ import { atom, useAtom, useAtomValue } from 'jotai' import { useCallback, useMemo } from 'react' import { gallerySettingAtom } from '~/atoms/app' +import { trackView } from '~/lib/tracker' const openAtom = atom(false) const currentIndexAtom = atom(0) @@ -52,6 +53,9 @@ export const usePhotoViewer = () => { const [currentIndex, setCurrentIndex] = useAtom(currentIndexAtom) const [triggerElement, setTriggerElement] = useAtom(triggerElementAtom) + const id = useMemo(() => { + return photos[currentIndex].id + }, [photos, currentIndex]) const openViewer = useCallback( (index: number, element?: HTMLElement) => { setCurrentIndex(index) @@ -59,8 +63,10 @@ export const usePhotoViewer = () => { setIsOpen(true) // 防止背景滚动 document.body.style.overflow = 'hidden' + + trackView(id) }, - [setCurrentIndex, setIsOpen, setTriggerElement], + [id, setCurrentIndex, setIsOpen, setTriggerElement], ) const closeViewer = useCallback(() => { @@ -70,25 +76,14 @@ export const usePhotoViewer = () => { document.body.style.overflow = '' }, [setIsOpen, setTriggerElement]) - const goToNext = useCallback(() => { - if (currentIndex < photos.length - 1) { - setCurrentIndex(currentIndex + 1) - } - }, [currentIndex, photos.length, setCurrentIndex]) - - const goToPrevious = useCallback(() => { - if (currentIndex > 0) { - setCurrentIndex(currentIndex - 1) - } - }, [currentIndex, setCurrentIndex]) - const goToIndex = useCallback( (index: number) => { if (index >= 0 && index < photos.length) { setCurrentIndex(index) + trackView(photos[index].id) } }, - [photos.length, setCurrentIndex], + [photos, setCurrentIndex], ) return { @@ -97,8 +92,7 @@ export const usePhotoViewer = () => { triggerElement, openViewer, closeViewer, - goToNext, - goToPrevious, + goToIndex, } } diff --git a/apps/web/src/lib/tracker.ts b/apps/web/src/lib/tracker.ts new file mode 100644 index 00000000..927920d0 --- /dev/null +++ b/apps/web/src/lib/tracker.ts @@ -0,0 +1,10 @@ + +import { injectConfig } from '~/config' +import { client } from '~/lib/client' + +export const trackView = (photoId: string) => { + if (!injectConfig.useApi) return + client.actView({ + refKey: photoId, + }) +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bad91dff..92ba8dca 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process' import { rmSync } from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' @@ -18,6 +19,9 @@ import { createFeedSitemapPlugin } from '../../plugins/vite/feed-sitemap' import { localesJsonPlugin } from '../../plugins/vite/locales-json' import { manifestInjectPlugin } from '../../plugins/vite/manifest-inject' import { siteConfig } from '../../site.config' +import { astPlugin } from './plugins/vite/ast' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) if (process.env.CI) { rmSync(path.join(process.cwd(), 'src/pages/(debug)'), { @@ -40,10 +44,12 @@ export default defineConfig({ }, }), + astPlugin, tsconfigPaths(), checker({ typescript: true, enableBuild: true, + root: __dirname, }), codeInspectorPlugin({ bundler: 'vite', diff --git a/locales/app/en.json b/locales/app/en.json index 2727d67e..42c952dd 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -240,6 +240,7 @@ "photo.live.tooltip.desktop.zoom": "Hover to play Live Photo / Double-click to zoom", "photo.live.tooltip.mobile.main": "Long press to play Live Photo", "photo.live.tooltip.mobile.zoom": "Long press to play Live Photo / Double-tap to zoom", + "photo.reaction.success": "Reaction added", "photo.share.actions": "Actions", "photo.share.copy.failed": "Copy failed", "photo.share.copy.link": "Copy Link", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e824b90d..f5e9619c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.6.0(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.41.0)(tsx@4.20.3)(yaml@2.8.0)) + ast-kit: + specifier: 2.1.0 + version: 2.1.0 babel-plugin-react-compiler: specifier: 19.1.0-rc.2 version: 19.1.0-rc.2 @@ -408,6 +411,9 @@ importers: tailwindcss-uikit-colors: specifier: 1.0.0-alpha.1 version: 1.0.0-alpha.1 + unplugin-ast: + specifier: 0.15.0 + version: 0.15.0 vite-plugin-html: specifier: 3.2.2 version: 3.2.2(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.41.0)(tsx@4.20.3)(yaml@2.8.0)) @@ -4573,6 +4579,10 @@ packages: resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} engines: {node: '>=12'} + magic-string-ast@0.9.1: + resolution: {integrity: sha512-18dv2ZlSSgJ/jDWlZGKfnDJx56ilNlYq9F7NnwuWTErsmYmqJ2TWE4l1o2zlUHBYUGBy3tIhPCC1gxq8M5HkMA==} + engines: {node: '>=20.18.0'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -5772,6 +5782,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unplugin-ast@0.15.0: + resolution: {integrity: sha512-3ReKQUmmYEcNhjoyiwfFuaJU0jkZNcNk8+iLdLVWk73iojVjJLiF/QhnpAFf3O7CJd6bqhWBzNyQ68Udp2fi5Q==} + engines: {node: '>=20.18.0'} + unplugin-dts@1.0.0-beta.2: resolution: {integrity: sha512-sgM7qrwWNyiBwoUexkHRrTQBb4xn6EGLkkrcovpG+MvOhYQPY0XdtNGtlxVdExcxTTtWIjpoAAYu5p0iQVQdAA==} peerDependencies: @@ -6653,14 +6667,14 @@ snapshots: '@babel/generator@7.27.5': dependencies: '@babel/parser': 7.27.5 - '@babel/types': 7.27.3 + '@babel/types': 7.27.6 '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.1': dependencies: - '@babel/types': 7.27.3 + '@babel/types': 7.27.6 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -6686,14 +6700,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.3 + '@babel/types': 7.27.6 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/types': 7.27.6 transitivePeerDependencies: - supports-color @@ -6717,7 +6731,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.27.3 + '@babel/types': 7.27.6 '@babel/helper-plugin-utils@7.27.1': {} @@ -6733,7 +6747,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.3 + '@babel/types': 7.27.6 transitivePeerDependencies: - supports-color @@ -6759,7 +6773,7 @@ snapshots: '@babel/parser@7.27.5': dependencies: - '@babel/types': 7.27.3 + '@babel/types': 7.27.6 '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.27.1)': dependencies: @@ -6790,7 +6804,7 @@ snapshots: '@babel/traverse@7.27.1': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.1 + '@babel/generator': 7.27.5 '@babel/parser': 7.27.2 '@babel/template': 7.27.2 '@babel/types': 7.27.1 @@ -8976,7 +8990,7 @@ snapshots: '@vue/compiler-core@3.5.16': dependencies: - '@babel/parser': 7.27.2 + '@babel/parser': 7.27.5 '@vue/shared': 3.5.16 entities: 4.5.0 estree-walker: 2.0.2 @@ -10046,8 +10060,8 @@ snapshots: espree@9.6.1: dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 3.4.3 esquery@1.6.0: @@ -10642,6 +10656,10 @@ snapshots: luxon@3.6.1: {} + magic-string-ast@0.9.1: + dependencies: + magic-string: 0.30.17 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -11765,6 +11783,13 @@ snapshots: universalify@2.0.1: {} + unplugin-ast@0.15.0: + dependencies: + '@babel/generator': 7.27.5 + ast-kit: 2.1.0 + magic-string-ast: 0.9.1 + unplugin: 2.3.5 + unplugin-dts@1.0.0-beta.2(@microsoft/api-extractor@7.52.8(@types/node@24.0.4))(esbuild@0.25.4)(rolldown@1.0.0-beta.11-commit.f051675)(rollup@4.41.1)(typescript@5.8.3)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.41.0)(tsx@4.20.3)(yaml@2.8.0)): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.41.1) @@ -11794,7 +11819,7 @@ snapshots: unplugin@2.3.5: dependencies: - acorn: 8.14.1 + acorn: 8.15.0 picomatch: 4.0.2 webpack-virtual-modules: 0.6.2