feat(data-sync): enhance error handling in data synchronization process

- Added an 'errors' field to the DataSyncResultSummary and updated related types to track synchronization errors.
- Modified the DataSyncService to categorize certain failures as errors instead of conflicts.
- Updated UI components to display error counts in synchronization summaries and progress panels, improving user feedback during data sync operations.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-14 17:54:01 +08:00
parent 5e4b4bb4d1
commit 51a5a51f20
10 changed files with 54 additions and 10 deletions

View File

@@ -322,6 +322,7 @@ export class DataSyncService {
deleted: 0,
conflicts: 0,
skipped: 0,
errors: 0,
}
}
@@ -378,9 +379,9 @@ export class DataSyncService {
})
if (!result?.item) {
summary.conflicts += 1
summary.errors += 1
const action: DataSyncAction = {
type: 'conflict',
type: 'error',
storageKey: storageObject.key,
photoId: null,
applied: false,

View File

@@ -6,7 +6,7 @@ export enum ConflictResolutionStrategy {
PREFER_DATABASE = 'prefer-database',
}
export type DataSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop'
export type DataSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop' | 'error'
export interface SyncObjectSnapshot {
size: number | null
@@ -47,6 +47,7 @@ export interface DataSyncResultSummary {
deleted: number
conflicts: number
skipped: number
errors: number
}
export interface DataSyncResult {

View File

@@ -8,7 +8,7 @@ type DeleteFromStorageOptionProps = {
export function DeleteFromStorageOption({ defaultChecked = false, disabled, onChange }: DeleteFromStorageOptionProps) {
return (
<label className="flex w-full items-start gap-3 rounded-xl border border-border/50 bg-background-secondary/40 px-3 py-2 text-left text-text">
<label className="flex w-full items-start gap-3 my-2 text-left text-text">
<Checkbox
size="md"
defaultChecked={defaultChecked}

View File

@@ -47,9 +47,9 @@ export function PhotoSyncActions({ onCompleted, onProgress, onError }: PhotoSync
},
onSuccess: (data, variables) => {
onCompleted(data, { dryRun: variables.dryRun ?? false })
const { inserted, updated, conflicts } = data.summary
const { inserted, updated, conflicts, errors } = data.summary
toast.success(variables.dryRun ? '同步预览完成' : '照片同步完成', {
description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts}`,
description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts} · 错误 ${errors}`,
})
},
onError: (error) => {

View File

@@ -51,6 +51,7 @@ const SUMMARY_FIELDS: Array<{
{ key: 'inserted', label: '新增' },
{ key: 'updated', label: '更新' },
{ key: 'conflicts', label: '冲突' },
{ key: 'errors', label: '错误' },
]
const timeFormatter = new Intl.DateTimeFormat(undefined, {
@@ -160,7 +161,7 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
))}
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{summaryItems.map((item) => (
<div key={item.label} className="bg-background-secondary/60 border-border/20 rounded-lg border p-4">
<p className="text-text-tertiary text-xs tracking-wide uppercase">{item.label}</p>

View File

@@ -78,6 +78,11 @@ export function PhotoSyncResultPanel({
value: result.summary.conflicts,
tone: result.summary.conflicts > 0 ? ('warning' as const) : ('muted' as const),
},
{
label: '错误条目',
value: result.summary.errors,
tone: result.summary.errors > 0 ? ('warning' as const) : ('muted' as const),
},
{
label: '跳过条目',
value: result.summary.skipped,
@@ -115,6 +120,7 @@ export function PhotoSyncResultPanel({
update: 0,
delete: 0,
conflict: 0,
error: 0,
noop: 0,
}
@@ -385,7 +391,7 @@ export function PhotoSyncResultPanel({
delay: index * 0.03,
}}
>
<div className="border-border/20 bg-fill/10 relative overflow-hidden rounded-lg">
<div className="border-border/20 mt-4 bg-fill/10 relative overflow-hidden rounded-lg">
<BorderOverlay />
<div className="space-y-3 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">

View File

@@ -27,5 +27,6 @@ export const PHOTO_ACTION_TYPE_CONFIG: Record<PhotoSyncAction['type'], { label:
update: { label: '更新', badgeClass: 'bg-sky-500/10 text-sky-400' },
delete: { label: '删除', badgeClass: 'bg-rose-500/10 text-rose-400' },
conflict: { label: '冲突', badgeClass: 'bg-amber-500/10 text-amber-400' },
error: { label: '错误', badgeClass: 'bg-rose-500/20 text-rose-200' },
noop: { label: '跳过', badgeClass: 'bg-slate-500/10 text-slate-400' },
}

View File

@@ -1,6 +1,6 @@
import type { PhotoManifestItem } from '@afilmory/builder'
export type PhotoSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop'
export type PhotoSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop' | 'error'
export type PhotoSyncResolution = 'prefer-storage' | 'prefer-database' | undefined
@@ -36,6 +36,7 @@ export interface PhotoSyncResultSummary {
deleted: number
conflicts: number
skipped: number
errors: number
}
export interface PhotoSyncResult {

View File

@@ -81,6 +81,38 @@ export function encodeS3Key(key: string): string {
.join('/')
}
function canonicalizePath(pathname: string): string {
if (!pathname || pathname === '/') {
return '/'
}
const segments = pathname.split('/').map((segment) => encodeRfc3986(safeDecodeURIComponent(segment)))
let canonical = segments.join('/')
if (!canonical.startsWith('/')) {
canonical = `/${canonical}`
}
if (pathname.endsWith('/') && !canonical.endsWith('/')) {
canonical = `${canonical}/`
}
return canonical || '/'
}
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function encodeRfc3986(value: string): string {
// eslint-disable-next-line unicorn/prefer-code-point
return encodeURIComponent(value).replaceAll(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
}
const EMPTY_HASH = crypto.createHash('sha256').update('').digest('hex')
class SigV4Signer {
@@ -142,7 +174,7 @@ class SigV4Signer {
}
private buildCanonicalRequest(method: string, url: URL, headers: Headers, payloadHash: string): string {
const canonicalUri = encodeURI(url.pathname).replaceAll('%2F', '/')
const canonicalUri = canonicalizePath(url.pathname)
const canonicalQuery = buildCanonicalQuery(url.searchParams)
const canonicalHeaders = buildCanonicalHeaders(headers)
const signedHeaders = this.getSignedHeaders(headers)

View File

@@ -192,6 +192,7 @@ export class S3StorageProvider implements StorageProvider {
if (!response.ok || !response.body) {
const bodyText = await response.text().catch(() => '')
logger.s3.error(`S3 响应异常:${key} (status ${response.status}) ${formatS3ErrorBody(bodyText)}`)
logger.s3.error(bodyText)
return null
}