feat(data-sync): implement logging for data synchronization process

- Added logging functionality to the DataSyncService to track progress and errors during the manifest generation process.
- Introduced DataSyncLogLevel and DataSyncLogPayload types for structured logging.
- Updated emitLog method to handle different log levels (info, success, warn, error) and include relevant details.
- Enhanced PhotoSyncProgressState to maintain a log of synchronization events, displayed in the PhotoSyncProgressPanel.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-13 00:42:43 +08:00
parent 42c217e2f8
commit ff73de44c8
6 changed files with 244 additions and 7 deletions

View File

@@ -14,6 +14,7 @@ import type {
ConflictPayload,
DataSyncAction,
DataSyncConflict,
DataSyncLogLevel,
DataSyncOptions,
DataSyncProgressEmitter,
DataSyncProgressStage,
@@ -368,6 +369,10 @@ export class DataSyncService {
const result = await this.safeProcessStorageObject(storageObject, builder, {
livePhotoMap,
progress: {
emitter: onProgress,
stage: 'missing-in-db',
},
})
if (!result?.item) {
@@ -944,6 +949,33 @@ export class DataSyncService {
})
}
private async emitLog(
emitter: DataSyncProgressEmitter | undefined,
payload: {
level: DataSyncLogLevel
message: string
stage?: DataSyncProgressStage | null
storageKey?: string
details?: Record<string, unknown> | null
},
): Promise<void> {
if (!emitter) {
return
}
await emitter({
type: 'log',
payload: {
level: payload.level,
message: payload.message,
stage: payload.stage ?? null,
storageKey: payload.storageKey,
details: payload.details ?? null,
timestamp: new Date().toISOString(),
},
})
}
private async emitComplete(emitter: DataSyncProgressEmitter | undefined, result: DataSyncResult): Promise<void> {
if (!emitter) {
return
@@ -1035,10 +1067,29 @@ export class DataSyncService {
options: {
existing?: PhotoManifestItem | null
livePhotoMap?: Map<string, StorageObject>
progress?: {
emitter?: DataSyncProgressEmitter
stage?: DataSyncProgressStage | null
}
},
) {
const progressContext = options.progress
const stage = progressContext?.stage ?? null
const emitter = progressContext?.emitter
await this.emitLog(emitter, {
level: 'info',
message: '开始生成 manifest',
stage,
storageKey: storageObject.key,
details: {
hasExistingManifest: Boolean(options.existing),
hasLivePhotoMap: Boolean(options.livePhotoMap),
},
})
try {
return await this.photoBuilderService.processPhotoFromStorageObject(storageObject, {
const result = await this.photoBuilderService.processPhotoFromStorageObject(storageObject, {
existingItem: options.existing ?? undefined,
livePhotoMap: options.livePhotoMap,
processorOptions: {
@@ -1047,7 +1098,42 @@ export class DataSyncService {
},
builder,
})
if (result?.item) {
await this.emitLog(emitter, {
level: 'success',
message: '生成 manifest 成功',
stage,
storageKey: storageObject.key,
details: {
photoId: result.item.id,
resultType: result.type ?? null,
},
})
} else {
await this.emitLog(emitter, {
level: 'warn',
message: '生成 manifest 未返回照片数据',
stage,
storageKey: storageObject.key,
details: {
resultType: result?.type ?? null,
},
})
}
return result
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
await this.emitLog(emitter, {
level: 'error',
message: '生成 manifest 失败',
stage,
storageKey: storageObject.key,
details: {
error: message,
},
})
this.logger.error('Failed to process storage object', err)
return null
}

View File

@@ -89,6 +89,17 @@ export interface DataSyncStageTotals {
'status-reconciliation': number
}
export type DataSyncLogLevel = 'info' | 'success' | 'warn' | 'error'
export interface DataSyncLogPayload {
level: DataSyncLogLevel
message: string
timestamp: string
stage?: DataSyncProgressStage | null
storageKey?: string
details?: Record<string, unknown> | null
}
export type DataSyncProgressEvent =
| {
type: 'start'
@@ -128,5 +139,9 @@ export type DataSyncProgressEvent =
message: string
}
}
| {
type: 'log'
payload: DataSyncLogPayload
}
export type DataSyncProgressEmitter = (event: DataSyncProgressEvent) => Promise<void> | void

View File

@@ -42,6 +42,8 @@ const STAGE_ORDER: PhotoSyncProgressStage[] = [
'status-reconciliation',
]
const MAX_SYNC_LOGS = 200
function createInitialStages(totals: PhotoSyncProgressState['totals']): PhotoSyncProgressState['stages'] {
return STAGE_ORDER.reduce<PhotoSyncProgressState['stages']>(
(acc, stage) => {
@@ -109,6 +111,7 @@ export function PhotoPage() {
stages: createInitialStages(totals),
startedAt: Date.now(),
updatedAt: Date.now(),
logs: [],
lastAction: undefined,
error: undefined,
})
@@ -135,6 +138,34 @@ export function PhotoPage() {
return
}
if (event.type === 'log') {
setSyncProgress((prev) => {
if (!prev) {
return prev
}
const parsedTimestamp = Date.parse(event.payload.timestamp)
const entry = {
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: Number.isNaN(parsedTimestamp) ? Date.now() : parsedTimestamp,
level: event.payload.level,
message: event.payload.message,
stage: event.payload.stage ?? null,
storageKey: event.payload.storageKey ?? undefined,
details: event.payload.details ?? null,
}
const nextLogs = prev.logs.length >= MAX_SYNC_LOGS ? [...prev.logs.slice(1), entry] : [...prev.logs, entry]
return {
...prev,
logs: nextLogs,
updatedAt: Date.now(),
}
})
return
}
if (event.type === 'stage') {
setSyncProgress((prev) => {
if (!prev) {

View File

@@ -2,7 +2,7 @@ import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
import type { PhotoSyncAction, PhotoSyncProgressStage, PhotoSyncProgressState } from '../../types'
import type { PhotoSyncAction, PhotoSyncLogLevel, PhotoSyncProgressStage, PhotoSyncProgressState } from '../../types'
import { BorderOverlay } from './PhotoSyncResultPanel'
const STAGE_CONFIG: Record<PhotoSyncProgressStage, { label: string; description: string }> = {
@@ -37,6 +37,13 @@ const STATUS_LABEL: Record<PhotoSyncProgressState['stages'][PhotoSyncProgressSta
completed: '已完成',
}
const LOG_LEVEL_CONFIG: Record<PhotoSyncLogLevel, { label: string; className: string }> = {
info: { label: '信息', className: 'border border-sky-500/30 bg-sky-500/10 text-sky-200' },
success: { label: '成功', className: 'border border-emerald-500/30 bg-emerald-500/10 text-emerald-200' },
warn: { label: '警告', className: 'border border-amber-500/30 bg-amber-500/10 text-amber-200' },
error: { label: '错误', className: 'border border-rose-500/30 bg-rose-500/10 text-rose-200' },
}
const SUMMARY_FIELDS: Array<{
key: keyof PhotoSyncProgressState['summary']
label: string
@@ -46,6 +53,20 @@ const SUMMARY_FIELDS: Array<{
{ key: 'conflicts', label: '冲突' },
]
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
function formatLogTimestamp(timestamp: number): string {
try {
return timeFormatter.format(timestamp)
} catch {
return '--:--:--'
}
}
type PhotoSyncProgressPanelProps = {
progress: PhotoSyncProgressState
}
@@ -94,6 +115,7 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
}))
const { lastAction } = progress
const recentLogs = progress.logs.slice(-8).reverse()
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-lg p-6">
@@ -147,6 +169,63 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
))}
</div>
{recentLogs.length > 0 ? (
<div className="border-border/20 bg-fill/10 mt-6 overflow-hidden rounded-lg border p-4">
<BorderOverlay />
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-text text-sm font-semibold"></p>
<span className="text-text-tertiary text-xs"> {recentLogs.length} </span>
</div>
<div className="mt-3 max-h-48 space-y-2 overflow-y-auto pr-1">
{recentLogs.map((log) => {
const levelConfig = LOG_LEVEL_CONFIG[log.level]
const details = (log.details ?? null) as Record<string, unknown> | null
const photoId = details && typeof details['photoId'] === 'string' ? (details['photoId'] as string) : null
const resultType =
details && typeof details['resultType'] === 'string' ? (details['resultType'] as string) : null
const error = details && typeof details['error'] === 'string' ? (details['error'] as string) : null
const hasExisting =
details && typeof details['hasExistingManifest'] === 'boolean'
? (details['hasExistingManifest'] as boolean)
: null
const hasLivePhotoMap =
details && typeof details['hasLivePhotoMap'] === 'boolean'
? (details['hasLivePhotoMap'] as boolean)
: null
const detailSegments: string[] = []
if (photoId) detailSegments.push(`ID ${photoId}`)
if (resultType) detailSegments.push(`结果 ${resultType}`)
if (typeof hasExisting === 'boolean') {
detailSegments.push(hasExisting ? '包含历史 manifest' : '无历史 manifest')
}
if (typeof hasLivePhotoMap === 'boolean') {
detailSegments.push(hasLivePhotoMap ? '包含 Live Photo' : '无 Live Photo')
}
if (error) detailSegments.push(`错误 ${error}`)
return (
<div
key={log.id}
className="bg-background-secondary/40 text-text flex flex-wrap items-center gap-2 rounded-md px-3 py-2 text-xs"
>
<span className="text-text-tertiary tabular-nums">{formatLogTimestamp(log.timestamp)}</span>
<span className={`${levelConfig.className} rounded-full px-2 py-0.5 text-[11px] font-medium`}>
{levelConfig.label}
</span>
<span className="text-text">{log.message}</span>
{log.storageKey ? <code className="text-text-secondary">{log.storageKey}</code> : null}
{log.stage ? <span className="text-text-tertiary">{STAGE_CONFIG[log.stage].label}</span> : null}
{detailSegments.length > 0 ? (
<span className="text-text-tertiary">{detailSegments.join(' · ')}</span>
) : null}
</div>
)
})}
</div>
</div>
) : null}
{lastAction ? (
<div className="border-border/20 bg-fill/10 mt-6 overflow-hidden rounded-lg border p-4">
<BorderOverlay />

View File

@@ -78,6 +78,17 @@ export interface PhotoSyncStageTotals {
'status-reconciliation': number
}
export type PhotoSyncLogLevel = 'info' | 'success' | 'warn' | 'error'
export interface PhotoSyncLogPayload {
level: PhotoSyncLogLevel
message: string
timestamp: string
stage?: PhotoSyncProgressStage | null
storageKey?: string
details?: Record<string, unknown> | null
}
export type PhotoSyncProgressEvent =
| {
type: 'start'
@@ -117,6 +128,10 @@ export type PhotoSyncProgressEvent =
message: string
}
}
| {
type: 'log'
payload: PhotoSyncLogPayload
}
export type PhotoSyncStageStatus = 'pending' | 'running' | 'completed'
@@ -134,6 +149,7 @@ export interface PhotoSyncProgressState {
>
startedAt: number
updatedAt: number
logs: PhotoSyncLogEntry[]
lastAction?: {
stage: PhotoSyncProgressStage
index: number
@@ -143,6 +159,16 @@ export interface PhotoSyncProgressState {
error?: string
}
export interface PhotoSyncLogEntry {
id: string
timestamp: number
level: PhotoSyncLogLevel
message: string
stage?: PhotoSyncProgressStage | null
storageKey?: string
details?: Record<string, unknown> | null
}
export interface PhotoAssetManifestPayload {
version: string
data: PhotoManifestItem

View File

@@ -14,6 +14,9 @@ import type { PickedExif } from '../types/photo.js'
export async function extractExifData(imageBuffer: Buffer, originalBuffer?: Buffer): Promise<PickedExif | null> {
const log = getGlobalLoggers().exif
await mkdir('/tmp/image_process', { recursive: true })
const tempImagePath = path.resolve('/tmp/image_process', `${crypto.randomUUID()}.jpg`)
try {
log.info('开始提取 EXIF 数据')
@@ -35,15 +38,10 @@ export async function extractExifData(imageBuffer: Buffer, originalBuffer?: Buff
return null
}
await mkdir('/tmp/image_process', { recursive: true })
const tempImagePath = path.resolve('/tmp/image_process', `${crypto.randomUUID()}.jpg`)
await writeFile(tempImagePath, originalBuffer || imageBuffer)
const exifData = await exiftool.read(tempImagePath)
const result = handleExifData(exifData, metadata)
await unlink(tempImagePath).catch(noop)
if (!exifData) {
log.warn('EXIF 数据解析失败')
return null
@@ -59,6 +57,8 @@ export async function extractExifData(imageBuffer: Buffer, originalBuffer?: Buff
} catch (error) {
log.error('提取 EXIF 数据失败:', error)
return null
} finally {
await unlink(tempImagePath).catch(noop)
}
}