diff --git a/CLAUDE.md b/CLAUDE.md index d520d24e..84c44e7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,7 +247,7 @@ per module is tracked in the API refactor plan. Tracked in `docs/plans/2026-05-19-api-refactor-design.md`. Snapshot: -1. **snake_case data model leak** — `Config.config_key/config_value`, `Album.album_value`, `Image.image_name`, `image_sorting`, `show_on_mainpage`, `Exif.data_time` are exposed in API responses and request bodies. Server-side mapping layer pending. (PR-07, PR-08, PR-09.) +1. **snake_case data model leak** — `Album.album_value`, `Image.image_name`, `image_sorting`, `show_on_mainpage` (PR-09) and `Exif.data_time` (PR-08) and the settings `Config.config_key/config_value` shape (PR-07) have been migrated to camelCase at the API boundary. Remaining snake_case fields still surfaced through `ImageType` / `AlbumType` (e.g. `preview_url`, `video_url`, `album_name`, `album_license`, `random_show`, `exposure_time`, `f_number`, `daily_weight`) and through the on-disk backup format are intentionally left for follow-up PRs — they are not part of the PR-07/08/09 scope. 2. **`/api/public/download/:id` dual return type** — Returns binary blob OR `{ url, filename }` JSON depending on direct-download config. To be split into `/download/:id` (binary) and `/download/:id/presigned` (JSON envelope). (PR-01.) 3. ~~`/api/public/images/image-blob` SSRF~~ — **DONE (PR-02):** Endpoint deleted. No in-repo consumers existed. 4. ~~`/api/v1/images/camera-lens-list` & `/api/public/camera-lens-list`~~ — **DONE (PR-04):** Both endpoints consolidated under Hono. Public moved to `GET /api/public/camera-lens` and reuses `fetchClientCameraAndLensList` / `fetchDailyCameraAndLensList`, which filter to `show=0` (visible) images and, for album-scoped requests, `albums.show=0` as well. Admin remains at `GET /api/v1/images/camera-lens-list` and returns the unfiltered set. diff --git a/app/(theme)/[...album]/page.tsx b/app/(theme)/[...album]/page.tsx index 0d892b49..7032b8b9 100644 --- a/app/(theme)/[...album]/page.tsx +++ b/app/(theme)/[...album]/page.tsx @@ -14,7 +14,7 @@ export default async function Page({ }) { const { album } = await params - const data: AlbumType = await fetchAlbumByRouter(`/${album}`) + const data: AlbumType | null = await fetchAlbumByRouter(`/${album}`) const props: ImageHandleProps = { handle: getImagesData, diff --git a/app/admin/settings/daily/page.tsx b/app/admin/settings/daily/page.tsx index dd17405e..c2b864fc 100644 --- a/app/admin/settings/daily/page.tsx +++ b/app/admin/settings/daily/page.tsx @@ -28,7 +28,7 @@ export default function DailySettings() { const [totalCount, setTotalCount] = useState('30') const [loading, setLoading] = useState(false) const [refreshing, setRefreshing] = useState(false) - const [albumWeights, setAlbumWeights] = useState>([]) + const [albumWeights, setAlbumWeights] = useState>([]) const t = useTranslations() const { data: configData, isValidating: configValidating, isLoading: configLoading, mutate: mutateConfig } = useSWR('/api/v1/daily/config', fetcher) @@ -44,7 +44,7 @@ export default function DailySettings() { useEffect(() => { if (albumsData) { - setAlbumWeights(albumsData.map((a: { id: string, name: string, album_value: string, daily_weight: number, photo_count: number }) => ({ ...a, daily_weight: Number(a.daily_weight) }))) + setAlbumWeights(albumsData.map((a: { id: string, name: string, albumValue: string, daily_weight: number, photo_count: number }) => ({ ...a, daily_weight: Number(a.daily_weight) }))) } }, [albumsData]) diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index fa02974f..e1d99d3a 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -49,10 +49,10 @@ export async function GET(request: Request) {
${item.detail}

${item.detail}

- 查看图片信息 + 查看图片信息
`, - url: url.origin + (item.album_value === '/' ? '/preview/' : item.album_value + '/preview/') + item.id, + url: url.origin + (item.albumValue === '/' ? '/preview/' : item.albumValue + '/preview/') + item.id, guid: item.id, date: item.created_at, enclosure: { diff --git a/components/admin/album/album-add-sheet.tsx b/components/admin/album/album-add-sheet.tsx index b30def4a..192d07f3 100644 --- a/components/admin/album/album-add-sheet.tsx +++ b/components/admin/album/album-add-sheet.tsx @@ -24,11 +24,11 @@ export default function AlbumAddSheet(props : Readonly) { const t = useTranslations() async function submit() { - if (!data.name || !data.album_value) { + if (!data.name || !data.albumValue) { toast.error(t('Album.requiredFields')) return } - if (data.album_value && data.album_value.charAt(0) !== '/') { + if (data.albumValue && data.albumValue.charAt(0) !== '/') { toast.error(t('Album.routerStartWithSlash')) return } @@ -92,9 +92,9 @@ export default function AlbumAddSheet(props : Readonly) { setData({...data, album_value: e.target.value})} + onChange={(e) => setData({...data, albumValue: e.target.value})} className="mt-1 w-full border-none p-0 focus:border-transparent focus:outline-none focus:ring-0 sm:text-sm" /> @@ -189,11 +189,11 @@ export default function AlbumAddSheet(props : Readonly) {
{t('Album.imageSortRule')}
setData({...data, album_value: e.target.value})} + onChange={(e) => setData({...data, albumValue: e.target.value})} className="mt-1 w-full border-none p-0 focus:border-transparent focus:outline-none focus:ring-0 sm:text-sm" /> @@ -193,11 +193,11 @@ export default function AlbumEditSheet(props : Readonly) {
{t('Album.imageSortRule')}
diff --git a/components/admin/upload/livephoto-file-upload.tsx b/components/admin/upload/livephoto-file-upload.tsx index 3004239e..7c33b11d 100644 --- a/components/admin/upload/livephoto-file-upload.tsx +++ b/components/admin/upload/livephoto-file-upload.tsx @@ -327,7 +327,7 @@ export default function LivephotoFileUpload() { {t('Words.album')} {albums?.map((album: AlbumType) => ( - + {album.name} ))} diff --git a/components/admin/upload/multiple-file-upload.tsx b/components/admin/upload/multiple-file-upload.tsx index 2e3debde..e8cec4b7 100644 --- a/components/admin/upload/multiple-file-upload.tsx +++ b/components/admin/upload/multiple-file-upload.tsx @@ -205,7 +205,7 @@ export default function MultipleFileUpload() { {t('Words.album')} {albums?.map((album: AlbumType) => ( - + {album.name} ))} diff --git a/components/admin/upload/simple-file-upload.tsx b/components/admin/upload/simple-file-upload.tsx index eb5c9384..f70539f3 100644 --- a/components/admin/upload/simple-file-upload.tsx +++ b/components/admin/upload/simple-file-upload.tsx @@ -125,7 +125,7 @@ export default function SimpleFileUpload() { id: imageId, album: album, url: url, - image_name: imageName, + imageName: imageName, title: title, preview_url: previewUrl, video_url: videoUrl, @@ -284,7 +284,7 @@ export default function SimpleFileUpload() { {t('Words.album')} {albums?.map((album: AlbumType) => ( - + {album.name} ))} diff --git a/components/album/preview-image-exif.tsx b/components/album/preview-image-exif.tsx index f3a7fd6d..6ba93fc4 100644 --- a/components/album/preview-image-exif.tsx +++ b/components/album/preview-image-exif.tsx @@ -118,7 +118,7 @@ export default function PreviewImageExif(props: Readonly) {
{t('Exif.basicInfo')}
- + {dimensions && } {megaPixels && } diff --git a/components/album/preview-image.tsx b/components/album/preview-image.tsx index e54f62a7..dd860be5 100644 --- a/components/album/preview-image.tsx +++ b/components/album/preview-image.tsx @@ -113,8 +113,8 @@ export default function PreviewImage(props: Readonly) { return } } - if (props.data?.album_value) { - router.push(`${props.data.album_value}`) + if (props.data?.albumValue) { + router.push(`${props.data.albumValue}`) } else { router.push('/') } diff --git a/components/layout/top-nav.tsx b/components/layout/top-nav.tsx index cf12978b..c35e19cc 100644 --- a/components/layout/top-nav.tsx +++ b/components/layout/top-nav.tsx @@ -75,9 +75,9 @@ export default function TopNav(props: Readonly) { props.data.map((album: AlbumType) => ( { app.post('/', async (c) => { const album = await c.req.json() - if (album.album_value && album.album_value.charAt(0) !== '/') { + if (album.albumValue && album.albumValue.charAt(0) !== '/') { throw badRequest('The route must start with /') } try { @@ -31,7 +31,7 @@ app.post('/', async (c) => { app.put('/', async (c) => { const album = await c.req.json() - if (album.album_value && album.album_value.charAt(0) !== '/') { + if (album.albumValue && album.albumValue.charAt(0) !== '/') { throw badRequest('The route must start with /') } try { diff --git a/hono/open/download.ts b/hono/open/download.ts index da4dec7d..37cf25b1 100644 --- a/hono/open/download.ts +++ b/hono/open/download.ts @@ -155,7 +155,7 @@ app.get('/:id/presigned', async (c) => { try { const { imageData, imageUrl } = await loadImageOrThrow(id) - const filename = deriveFilename(imageData.image_name, imageUrl) + const filename = deriveFilename(imageData.imageName, imageUrl) const url = await buildPresignedUrlForStorage(storage, imageUrl) c.header('Cache-Control', 'private, max-age=60') return ok(c, { url, filename: encodeURIComponent(filename) }) @@ -182,7 +182,7 @@ app.get('/:id', async (c) => { try { const { imageData, imageUrl } = await loadImageOrThrow(id) - const filename = deriveFilename(imageData.image_name, imageUrl) + const filename = deriveFilename(imageData.imageName, imageUrl) const response = await fetch(imageUrl) if (!response.ok) { diff --git a/server/db/operate/albums.ts b/server/db/operate/albums.ts index 0eaa2531..f65b1c24 100644 --- a/server/db/operate/albums.ts +++ b/server/db/operate/albums.ts @@ -3,6 +3,10 @@ 'use server' import { db } from '~/server/lib/db' +import { + toAlbumPrismaCreate, + toAlbumPrismaUpdate, +} from '~/server/lib/model-transform' import type { AlbumType } from '~/types' /** @@ -14,18 +18,7 @@ export async function insertAlbums(album: AlbumType) { album.sort = 0 } return await db.albums.create({ - data: { - name: album.name, - album_value: album.album_value, - detail: album.detail, - sort: album.sort, - theme: album.theme, - show: album.show, - license: album.license, - del: 0, - image_sorting: album.image_sorting, - random_show: album.random_show, - } + data: toAlbumPrismaCreate(album) }) } @@ -66,25 +59,14 @@ export async function updateAlbum(album: AlbumType) { where: { id: album.id }, - data: { - name: album.name, - album_value: album.album_value, - detail: album.detail, - sort: album.sort, - theme: album.theme, - show: album.show, - license: album.license, - updatedAt: new Date(), - image_sorting: album.image_sorting, - random_show: album.random_show, - } + data: toAlbumPrismaUpdate(album) }) await tx.imagesAlbumsRelation.updateMany({ where: { album_value: tagOld.album_value }, data: { - album_value: album.album_value + album_value: album.albumValue } }) }) diff --git a/server/db/operate/images.ts b/server/db/operate/images.ts index 7b7c48b8..2ee5bfe1 100644 --- a/server/db/operate/images.ts +++ b/server/db/operate/images.ts @@ -3,6 +3,10 @@ 'use server' import { db } from '~/server/lib/db' +import { + toImagePrismaCreate, + toImagePrismaUpdate, +} from '~/server/lib/model-transform' import type { ImageType } from '~/types' /** @@ -15,26 +19,7 @@ export async function insertImage(image: ImageType) { } await db.$transaction(async (tx) => { const resultRow = await tx.images.create({ - data: { - id: image.id, - image_name: image.image_name, - url: image.url, - title: image.title, - blurhash: image.blurhash, - preview_url: image.preview_url, - video_url: image.video_url, - exif: image.exif, - labels: image.labels, - width: image.width, - height: image.height, - detail: image.detail, - lat: String(image.lat), - lon: String(image.lon), - type: image.type, - show: 1, - sort: image.sort, - del: 0, - } + data: toImagePrismaCreate(image) }) if (resultRow) { @@ -114,24 +99,7 @@ export async function updateImage(image: ImageType) { where: { id: image.id }, - data: { - url: image.url, - title: image.title, - preview_url: image.preview_url, - video_url: image.video_url, - blurhash: image.blurhash, - exif: image.exif, - labels: image.labels, - detail: image.detail, - sort: image.sort, - show: image.show, - show_on_mainpage: image.show_on_mainpage, - width: image.width, - height: image.height, - lat: image.lat, - lon: image.lon, - updatedAt: new Date(), - } + data: toImagePrismaUpdate(image) }) }) } diff --git a/server/db/query/albums.ts b/server/db/query/albums.ts index 34a4adcb..1773c2e7 100644 --- a/server/db/query/albums.ts +++ b/server/db/query/albums.ts @@ -3,6 +3,7 @@ 'use server' import { db } from '~/server/lib/db' +import { toAlbum, toAlbumList } from '~/server/lib/model-transform' import type { AlbumType } from '~/types' /** @@ -10,7 +11,7 @@ import type { AlbumType } from '~/types' * @returns {Promise} 相册列表 */ export async function fetchAlbumsList(): Promise { - return await db.albums.findMany({ + const rows = await db.albums.findMany({ where: { del: 0 }, @@ -26,6 +27,7 @@ export async function fetchAlbumsList(): Promise { } ] }) + return toAlbumList(rows) } /** @@ -33,7 +35,7 @@ export async function fetchAlbumsList(): Promise { * @returns {Promise} 相册列表 */ export async function fetchAlbumsShow(): Promise { - return await db.albums.findMany({ + const rows = await db.albums.findMany({ where: { del: 0, show: 0, @@ -47,18 +49,20 @@ export async function fetchAlbumsShow(): Promise { } ] }) + return toAlbumList(rows) } /** * 获取对应路由的相册信息 * @param router 相册路由 */ -export async function fetchAlbumByRouter(router: string): Promise { - return await db.albums.findFirst({ +export async function fetchAlbumByRouter(router: string): Promise { + const row = await db.albums.findFirst({ where: { del: 0, show: 0, album_value: router }, }) + return row ? toAlbum(row) : null } diff --git a/server/db/query/daily.ts b/server/db/query/daily.ts index fe9d02e3..fc018959 100644 --- a/server/db/query/daily.ts +++ b/server/db/query/daily.ts @@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client' import { db } from '~/server/lib/db' +import { mapRawImageRows } from '~/server/lib/model-transform' import type { ImageType } from '~/types' const DEFAULT_SIZE = 24 @@ -23,7 +24,7 @@ export async function fetchDailyImagesList( if (pageNum < 1) { pageNum = 1 } - return await db.$queryRaw` + const rows = await db.$queryRaw` SELECT image.* FROM @@ -35,6 +36,7 @@ export async function fetchDailyImagesList( ORDER BY image.daily_sort LIMIT ${DEFAULT_SIZE} OFFSET ${(pageNum - 1) * DEFAULT_SIZE} ` + return mapRawImageRows(rows) } /** @@ -87,7 +89,7 @@ export async function fetchDailyCameraAndLensList(): Promise<{ cameras: string[] export async function fetchAlbumsWithDailyWeight(): Promise> { @@ -95,7 +97,7 @@ export async function fetchAlbumsWithDailyWeight(): Promise` + SELECT image.*, albums.name AS album_name, - albums.id AS album_value - FROM + albums.id AS "albumValue" + FROM "public"."images" AS image INNER JOIN "public"."images_albums_relation" AS relation ON image.id = relation."imageId" @@ -68,25 +69,27 @@ export async function fetchServerImagesListByAlbum( ORDER BY image.sort DESC, image.created_at DESC, image.updated_at DESC ${buildPagination(pageNum, pageSize)} ` + return mapRawImageRows(rows) } - return await db.$queryRaw` - SELECT + const rows = await db.$queryRaw` + SELECT image.*, albums.name AS album_name, - albums.id AS album_value - FROM + albums.id AS "albumValue" + FROM "public"."images" AS image LEFT JOIN "public"."images_albums_relation" AS relation ON image.id = relation."imageId" LEFT JOIN "public"."albums" AS albums ON relation.album_value = albums.album_value - WHERE + WHERE image.del = 0 ${buildShowFilter(showStatus)} ${buildExifFilters(camera, lens)} ORDER BY image.sort DESC, image.created_at DESC, image.updated_at DESC ${buildPagination(pageNum, pageSize)} ` + return mapRawImageRows(rows) } /** @@ -165,7 +168,7 @@ export async function fetchClientImagesListByAlbum( lens?: string ): Promise { if (album === '/') { - return await db.$queryRaw` + const rows = await db.$queryRaw` SELECT image.* FROM @@ -180,6 +183,7 @@ export async function fetchClientImagesListByAlbum( ORDER BY image.sort DESC, image.created_at DESC, image.updated_at DESC ${buildPagination(pageNum, DEFAULT_SIZE)} ` + return mapRawImageRows(rows) } const albumData = await db.albums.findFirst({ where: { @@ -190,14 +194,14 @@ export async function fetchClientImagesListByAlbum( if (albumData && albumData.image_sorting && ALBUM_IMAGE_SORTING_ORDER[albumData.image_sorting]) { orderBy = Prisma.sql([`image.sort DESC, ${ALBUM_IMAGE_SORTING_ORDER[albumData.image_sorting]}`]) } - const dataList: any[] = await db.$queryRaw` - SELECT + const dataList = await db.$queryRaw` + SELECT image.*, albums.name AS album_name, - albums.id AS album_value, + albums.id AS "albumValue", albums.license AS album_license, albums.image_sorting AS album_image_sorting - FROM + FROM "public"."images" AS image INNER JOIN "public"."images_albums_relation" AS relation ON image.id = relation."imageId" @@ -217,10 +221,11 @@ export async function fetchClientImagesListByAlbum( ORDER BY ${orderBy} ${buildPagination(pageNum, DEFAULT_SIZE)} ` - if (dataList && albumData && albumData.random_show === 0) { - return [...dataList].sort(() => Math.random() - 0.5) + const mapped = mapRawImageRows(dataList) + if (mapped && albumData && albumData.random_show === 0) { + return [...mapped].sort(() => Math.random() - 0.5) } - return dataList + return mapped } /** @@ -286,8 +291,8 @@ export async function fetchClientImagesPageTotalByAlbum( * @returns {Promise} 图片列表 */ export async function fetchMapImages(): Promise { - return await db.$queryRaw` - SELECT + const rows = await db.$queryRaw` + SELECT image.id, image.title, image.url, @@ -298,8 +303,8 @@ export async function fetchMapImages(): Promise { image.lon, image.exif, image.show, - image.show_on_mainpage - FROM + image.show_on_mainpage AS "showOnMainpage" + FROM "public"."images" AS image WHERE image.del = 0 @@ -309,12 +314,13 @@ export async function fetchMapImages(): Promise { image.lat IS NOT NULL AND image.lon IS NOT NULL - AND + AND image.lat != '' - AND + AND image.lon != '' ORDER BY image.created_at DESC ` + return rows } /** @@ -324,11 +330,11 @@ export async function fetchMapImages(): Promise { * @returns {Promise} 图片列表 */ export async function fetchClientImagesListByTag(pageNum: number, tag: string): Promise { - return await db.$queryRaw` + const rows = await db.$queryRaw` SELECT image.*, albums.name AS album_name, - albums.id AS album_value, + albums.id AS "albumValue", albums.license AS album_license FROM "public"."images" AS image @@ -349,6 +355,7 @@ export async function fetchClientImagesListByTag(pageNum: number, tag: string): ORDER BY image.sort DESC, image.created_at DESC, image.updated_at DESC ${buildPagination(pageNum, DEFAULT_SIZE)} ` + return mapRawImageRows(rows) } /** @@ -454,11 +461,11 @@ export async function fetchImagesAnalysis(): * @returns {Promise} 图片详情 */ export async function fetchImageByIdAndAuth(id: string): Promise { - const data: ImageType[] = await db.$queryRaw` + const data = await db.$queryRaw` SELECT "images".*, "albums".license AS album_license, - "albums".album_value AS album_value + "albums".album_value AS "albumValue" FROM "images" INNER JOIN "images_albums_relation" @@ -476,7 +483,7 @@ export async function fetchImageByIdAndAuth(id: string): Promise { AND "images".id = ${id} ` - return data[0] + return mapRawImageRows(data)[0] } /** @@ -485,11 +492,11 @@ export async function fetchImageByIdAndAuth(id: string): Promise { */ export async function getRSSImages(): Promise { // 每个相册取最新 10 张照片 - return await db.$queryRaw` + const rows = await db.$queryRaw` WITH RankedImages AS ( SELECT i.*, - A.album_value, + A.album_value AS "albumValue", ROW_NUMBER() OVER (PARTITION BY A.album_value ORDER BY i.created_at DESC) AS rn FROM images i @@ -505,6 +512,7 @@ export async function getRSSImages(): Promise { FROM RankedImages WHERE rn <= 10; ` + return mapRawImageRows(rows) } /** diff --git a/server/lib/model-transform.ts b/server/lib/model-transform.ts new file mode 100644 index 00000000..14b4903d --- /dev/null +++ b/server/lib/model-transform.ts @@ -0,0 +1,203 @@ +import 'server-only' + +import type { AlbumType, ImageType } from '~/types' + +// === Album mappers === +// +// PicImpact keeps DB column names snake_case (see `prisma/schema.prisma`) so +// Prisma rows access fields as `row.album_value`, `row.image_sorting`, etc. +// The API/UI contract uses camelCase for these four fields: +// - album_value → albumValue +// - image_sorting → imageSorting +// - image_name → imageName +// - show_on_mainpage → showOnMainpage +// +// These helpers convert at the boundary so callers can stay in one casing. + +// Shape of an `Albums` row coming back from Prisma (snake_case fields). +type PrismaAlbumRow = { + id: string + name: string + album_value: string + detail: string | null + theme: string + show: number + sort: number + license: string | null + image_sorting: number + random_show: number + del?: number + createdAt?: Date + updatedAt?: Date | null +} + +export function toAlbum(row: PrismaAlbumRow): AlbumType { + return { + id: row.id, + name: row.name, + albumValue: row.album_value, + detail: row.detail, + theme: row.theme, + show: row.show, + sort: row.sort, + license: row.license, + imageSorting: row.image_sorting, + random_show: row.random_show, + del: row.del, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +export function toAlbumList(rows: PrismaAlbumRow[]): AlbumType[] { + return rows.map(toAlbum) +} + +// Inputs the API/UI sends. Snake_case field names that Prisma understands are +// mapped here so callers never need to know about column naming. +type AlbumWriteInput = Pick & { + albumValue: string + imageSorting: number +} + +export function toAlbumPrismaCreate(input: AlbumWriteInput) { + return { + name: input.name, + album_value: input.albumValue, + detail: input.detail, + sort: input.sort, + theme: input.theme, + show: input.show, + license: input.license, + del: 0, + image_sorting: input.imageSorting, + random_show: input.random_show, + } +} + +export function toAlbumPrismaUpdate(input: AlbumWriteInput) { + return { + name: input.name, + album_value: input.albumValue, + detail: input.detail, + sort: input.sort, + theme: input.theme, + show: input.show, + license: input.license, + updatedAt: new Date(), + image_sorting: input.imageSorting, + random_show: input.random_show, + } +} + +// === Image mappers === +// +// Image rows arrive in two flavours: +// 1) Plain Prisma rows (snake_case fields) from `findMany`/`findFirst`. +// 2) Raw SQL rows that already alias the four canonical columns +// (`AS "albumValue"`, `AS "imageName"`, etc.); for those, prefer the +// `Prisma.sql` alias and skip the mapper. +// +// `toImage` accepts either shape and returns the camelCase API surface. + +type ImageInsertInput = { + id: string + imageName: string + url: string + title: string + blurhash: string + preview_url: string + video_url: string + exif: unknown + labels: unknown + width: number + height: number + detail: string + lat: string | number + lon: string | number + type: number + sort: number + album: string +} + +export function toImagePrismaCreate(input: ImageInsertInput) { + return { + id: input.id, + image_name: input.imageName, + url: input.url, + title: input.title, + blurhash: input.blurhash, + preview_url: input.preview_url, + video_url: input.video_url, + exif: input.exif as any, + labels: input.labels as any, + width: input.width, + height: input.height, + detail: input.detail, + lat: String(input.lat), + lon: String(input.lon), + type: input.type, + show: 1, + sort: input.sort, + del: 0, + } +} + +// Map a raw-SQL row (snake_case PostgreSQL columns) into the camelCase +// `ImageType` shape consumed by API responses and frontend consumers. +// +// Raw queries that use `image.*` return rows with snake_case keys; this helper +// renames the four canonical fields without touching the rest of the payload. +// Other snake_case fields (`preview_url`, `video_url`, `album_name`, ...) are +// left as-is — they're tracked separately in the API refactor plan. +export function mapRawImageRow>(row: T): T { + if (!row || typeof row !== 'object') return row + const next: Record = { ...row } + if ('image_name' in next) { + next.imageName = next.image_name + delete next.image_name + } + if ('show_on_mainpage' in next) { + next.showOnMainpage = next.show_on_mainpage + delete next.show_on_mainpage + } + if ('album_value' in next) { + next.albumValue = next.album_value + delete next.album_value + } + if ('image_sorting' in next) { + next.imageSorting = next.image_sorting + delete next.image_sorting + } + return next as T +} + +export function mapRawImageRows>(rows: T[]): T[] { + return rows.map(mapRawImageRow) +} + +type ImageUpdateInput = Pick< + ImageType, + 'id' | 'url' | 'title' | 'preview_url' | 'video_url' | 'blurhash' | 'exif' | 'labels' | 'detail' | 'sort' | 'show' | 'width' | 'height' | 'lat' | 'lon' +> & { showOnMainpage: number } + +export function toImagePrismaUpdate(input: ImageUpdateInput) { + return { + url: input.url, + title: input.title, + preview_url: input.preview_url, + video_url: input.video_url, + blurhash: input.blurhash, + exif: input.exif as any, + labels: input.labels as any, + detail: input.detail, + sort: input.sort, + show: input.show, + show_on_mainpage: input.showOnMainpage, + width: input.width, + height: input.height, + lat: input.lat, + lon: input.lon, + updatedAt: new Date(), + } +} diff --git a/server/tasks/metadata-refresh.ts b/server/tasks/metadata-refresh.ts index 29889bb5..2dbe698f 100644 --- a/server/tasks/metadata-refresh.ts +++ b/server/tasks/metadata-refresh.ts @@ -64,7 +64,7 @@ type CoordinateUpdateResult = { export type MetadataRefreshImage = { id: string - image_name: string | null + imageName: string | null title: string | null url: string | null exif: unknown @@ -119,8 +119,8 @@ function nullableString(value: unknown) { return normalized || null } -function pickImageTitle(image: Pick) { - return cleanString(image.title) || cleanString(image.image_name) || image.id +function pickImageTitle(image: Pick) { + return cleanString(image.title) || cleanString(image.imageName) || image.id } function toErrorDetail(error: unknown) { diff --git a/server/tasks/service.ts b/server/tasks/service.ts index 93db1a65..0ab9083a 100644 --- a/server/tasks/service.ts +++ b/server/tasks/service.ts @@ -272,8 +272,8 @@ function buildTaskScopeQuery(scope: AdminTaskScope) { } } -function imageTitle(image: Pick) { - return cleanString(image.title) || cleanString(image.image_name) || image.id +function imageTitle(image: Pick) { + return cleanString(image.title) || cleanString(image.imageName) || image.id } function createImageIssue(image: MetadataRefreshImage, input: IssueInput): AdminTaskIssue { @@ -344,7 +344,7 @@ async function fetchImagesBatchForScope(scope: AdminTaskScope, nextCursor: strin return db.$queryRaw` SELECT DISTINCT ON (image.id) image.id, - image.image_name, + image.image_name AS "imageName", image.title, image.url, image.exif, diff --git a/types/index.ts b/types/index.ts index cd3ab6ad..b3a11e92 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3,13 +3,13 @@ export type AlbumType = { id: string; name: string; - album_value: string; + albumValue: string; detail: string | null; theme: string; show: number; sort: number; license: string | null; - image_sorting: number; + imageSorting: number; random_show: number; del?: number; createdAt?: Date; @@ -36,7 +36,7 @@ export type ExifType = { export type ImageType = { id: string; - image_name: string; + imageName: string; title: string; url: string; preview_url: string; @@ -52,10 +52,10 @@ export type ImageType = { detail: string; type: number; // type: 图片类型为 1,livephoto 类型为 2 show: number; - show_on_mainpage: number; + showOnMainpage: number; sort: number; album_name: string; - album_value: string; + albumValue: string; album_license: string; }