diff --git a/api/services/memoir_images/settings.py b/api/services/memoir_images/settings.py index 9e5d439..bfaca0f 100644 --- a/api/services/memoir_images/settings.py +++ b/api/services/memoir_images/settings.py @@ -8,7 +8,7 @@ DEFAULT_MAX_IMAGES_CAP = 8 DEFAULT_IMAGE_PROVIDER = "liblib" DEFAULT_IMAGE_STYLE = "watercolor" DEFAULT_IMAGE_SIZE = "1280x720" -DEFAULT_POLL_INTERVAL_SECONDS = 3 +DEFAULT_POLL_INTERVAL_SECONDS = 5 DEFAULT_MAX_ATTEMPTS = 60 diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt index 60e4776..cd93fa9 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt @@ -66,7 +66,15 @@ fun ChapterReadingView( ) } - items(blocks.size) { index -> + items( + count = blocks.size, + key = { index -> + when (val block = blocks[index]) { + is MemoirContentBlock.Text -> "text-$index" + is MemoirContentBlock.Image -> "img-${block.image.index}" + } + }, + ) { index -> when (val block = blocks[index]) { is MemoirContentBlock.Text -> { val processedContent = TextUtils.addParagraphFirstLineIndent( diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt index 425bf59..f32051b 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt @@ -74,30 +74,37 @@ fun FullTextReadingView( splitMemoirContent(chapter.content, chapter.images) } - blocks.forEach { block -> - when (block) { - is MemoirContentBlock.Text -> { - val processedContent = TextUtils.addParagraphFirstLineIndent( - TextUtils.removeInlineChapterHeadings(block.content) - ) - MarkdownText( - content = processedContent, - modifier = Modifier.padding(bottom = 16.dp), - textColor = MaterialTheme.colorScheme.onSurface, - fontSize = AppTypography.titleMedium, - lineHeight = AppTypography.lineHeightXLoose - ) + blocks.forEachIndexed { blockIndex, block -> + key( + when (block) { + is MemoirContentBlock.Text -> "text-$blockIndex" + is MemoirContentBlock.Image -> "img-${block.image.index}" } - is MemoirContentBlock.Image -> { - MemoirInlineImage( - image = block.image, - onClick = { - if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) { - viewerImage = block.image - } - }, - modifier = Modifier.padding(vertical = 12.dp) - ) + ) { + when (block) { + is MemoirContentBlock.Text -> { + val processedContent = TextUtils.addParagraphFirstLineIndent( + TextUtils.removeInlineChapterHeadings(block.content) + ) + MarkdownText( + content = processedContent, + modifier = Modifier.padding(bottom = 16.dp), + textColor = MaterialTheme.colorScheme.onSurface, + fontSize = AppTypography.titleMedium, + lineHeight = AppTypography.lineHeightXLoose + ) + } + is MemoirContentBlock.Image -> { + MemoirInlineImage( + image = block.image, + onClick = { + if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) { + viewerImage = block.image + } + }, + modifier = Modifier.padding(vertical = 12.dp) + ) + } } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt index 19cc135..ed8a802 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt @@ -9,18 +9,23 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import coil.request.ImageRequest import com.huaga.life_echo.model.ChapterImageDto import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_COMPLETED import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_FAILED @@ -38,6 +43,90 @@ internal fun memoirImageAspectRatio(size: String?): Float { return width / height } +internal fun memoirImageCanonicalUrl(url: String?): String? { + val normalizedUrl = url?.trim().orEmpty() + if (normalizedUrl.isBlank()) return null + return normalizedUrl.substringBefore('#').substringBefore('?') +} + +internal fun memoirImageStableCacheKey(image: ChapterImageDto): String { + return listOf( + "memoir-image", + image.index.toString(), + image.placeholder, + memoirImageCanonicalUrl(image.url).orEmpty(), + image.updated_at.orEmpty(), + image.created_at.orEmpty(), + image.description, + ).joinToString("|") +} + +@Composable +private fun rememberMemoirImageRequest(image: ChapterImageDto): ImageRequest { + val context = LocalContext.current + val cacheKey = remember(image) { memoirImageStableCacheKey(image) } + + // COS 签名 URL 会轮换,但同一张图需要命中同一个缓存身份。 + return remember(context, image.url, cacheKey) { + ImageRequest.Builder(context) + .data(image.url) + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) + .build() + } +} + +private fun memoirImageModifier( + image: ChapterImageDto, + modifier: Modifier, + testTag: String, + onClick: (() -> Unit)? = null, +): Modifier { + var result = modifier + .fillMaxWidth() + .aspectRatio(memoirImageAspectRatio(image.size)) + .clip(RoundedCornerShape(16.dp)) + if (onClick != null) { + result = result.clickable(onClick = onClick) + } + return result.testTag(testTag) +} + +@Composable +private fun MemoirImageLoadingContent( + text: String, + alpha: Float, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LightPurple.copy(alpha = alpha)), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + fontSize = AppTypography.bodyMedium, + color = SlatePurple.copy(alpha = 0.6f), + ) + } +} + +@Composable +private fun MemoirImageStatusContent(text: String) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LightPurple.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + fontSize = AppTypography.bodyMedium, + color = SlatePurple.copy(alpha = 0.72f), + ) + } +} + @Composable private fun MemoirImageLoadingPlaceholder( image: ChapterImageDto, @@ -55,18 +144,16 @@ private fun MemoirImageLoadingPlaceholder( label = "shimmer-alpha" ) Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(memoirImageAspectRatio(image.size)) - .clip(RoundedCornerShape(16.dp)) - .background(LightPurple.copy(alpha = alpha)) - .testTag("memoir-image-loading-${image.index}"), + modifier = memoirImageModifier( + image = image, + modifier = modifier, + testTag = "memoir-image-loading-${image.index}", + ), contentAlignment = Alignment.Center, ) { - Text( + MemoirImageLoadingContent( text = text, - fontSize = AppTypography.bodyMedium, - color = SlatePurple.copy(alpha = 0.6f), + alpha = alpha, ) } } @@ -78,19 +165,14 @@ private fun MemoirImageStatusPlaceholder( modifier: Modifier = Modifier, ) { Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(memoirImageAspectRatio(image.size)) - .clip(RoundedCornerShape(16.dp)) - .background(LightPurple.copy(alpha = 0.16f)) - .testTag("memoir-image-error-${image.index}"), + modifier = memoirImageModifier( + image = image, + modifier = modifier, + testTag = "memoir-image-error-${image.index}", + ), contentAlignment = Alignment.Center, ) { - Text( - text = text, - fontSize = AppTypography.bodyMedium, - color = SlatePurple.copy(alpha = 0.72f), - ) + MemoirImageStatusContent(text = text) } } @@ -119,30 +201,43 @@ fun MemoirInlineImage( modifier = modifier, ) } else { + val request = rememberMemoirImageRequest(image) + SubcomposeAsyncImage( - model = image.url, + model = request, contentDescription = image.description, + modifier = memoirImageModifier( + image = image, + modifier = modifier, + testTag = "memoir-image-${image.index}", + onClick = onClick, + ), contentScale = ContentScale.FillWidth, loading = { - MemoirImageLoadingPlaceholder( - image = image, - modifier = modifier, - text = "图片加载中…", + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.10f, + targetValue = 0.25f, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse + ), + label = "shimmer-alpha" ) + MemoirImageLoadingContent( + text = "图片加载中…", + alpha = alpha, + ) + }, + success = { + SubcomposeAsyncImageContent() }, error = { - MemoirImageStatusPlaceholder( - image = image, - text = "图片暂不可用", - modifier = modifier, - ) + MemoirImageStatusContent(text = "图片暂不可用") }, - modifier = modifier - .fillMaxWidth() - .aspectRatio(memoirImageAspectRatio(image.size)) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick) - .testTag("memoir-image-${image.index}") + onLoading = { }, + onSuccess = { }, + onError = { }, ) } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt index 699c1c5..c8c1d5b 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt @@ -4,7 +4,7 @@ import com.huaga.life_echo.model.ChapterDto import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_PENDING import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_PROCESSING -internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 3_000L +internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 5_000L internal const val MEMOIR_IMAGE_PROVIDER_MAX_ATTEMPTS = 60L internal const val MEMOIR_IMAGE_POLL_GRACE_MS = 30_000L diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt index 5469e0d..272b3e7 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt @@ -253,8 +253,11 @@ fun MyMemoirScreen( val idx = chapterDtos.indexOfFirst { it.id == selectedChapter!!.id } if (idx >= 0) idx + 1 else null } - val chapterContent = remember(selectedChapter, chapterDtos) { - val chapterDto = chapterDtos.find { it.id == selectedChapter!!.id } + val selectedChapterDto = remember(selectedChapter!!.id, chapterDtos) { + chapterDtos.find { it.id == selectedChapter!!.id } + } + val selectedImages = selectedChapterDto?.images ?: emptyList() + val chapterContent = remember(selectedChapter, selectedImages) { ChapterContentDto( id = selectedChapter!!.id, title = selectedChapter!!.title, @@ -265,7 +268,7 @@ fun MyMemoirScreen( pageCount = null, updatedAt = selectedChapter!!.updatedAt, quotes = emptyList(), - images = chapterDto?.images ?: emptyList() + images = selectedImages ) } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt index 67614e8..57e7d17 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt @@ -2,6 +2,7 @@ package com.huaga.life_echo.ui.components.memoir import com.huaga.life_echo.model.ChapterImageDto import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Test class MemoirInlineImageStateTest { @@ -27,10 +28,65 @@ class MemoirInlineImageStateTest { assertEquals("图片暂不可用", memoirImageFailureText(image)) } + @Test + fun memoirImageCanonicalUrl_stripsSignedQueryParameters() { + val canonicalUrl = memoirImageCanonicalUrl( + "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1&q-ak=demo&q-sign-time=1" + ) + + assertEquals( + "https://cdn.example.com/memoirs/u1/c1/0-demo.png", + canonicalUrl, + ) + } + + @Test + fun memoirImageStableCacheKey_ignoresSignedUrlRotation_forSameImage() { + val first = image( + status = "completed", + retryable = null, + url = "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-time=1&q-signature=a", + updatedAt = "2026-03-13T10:00:00Z", + ) + val second = image( + status = "completed", + retryable = null, + url = "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-time=2&q-signature=b", + updatedAt = "2026-03-13T10:00:00Z", + ) + + assertEquals( + memoirImageStableCacheKey(first), + memoirImageStableCacheKey(second), + ) + } + + @Test + fun memoirImageStableCacheKey_changes_whenImageVersionChanges() { + val original = image( + status = "completed", + retryable = null, + url = "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-time=1", + updatedAt = "2026-03-13T10:00:00Z", + ) + val regenerated = image( + status = "completed", + retryable = null, + url = "https://cdn.example.com/memoirs/u1/c1/0-demo-v2.png?q-sign-time=2", + updatedAt = "2026-03-13T10:05:00Z", + ) + + assertNotEquals( + memoirImageStableCacheKey(original), + memoirImageStableCacheKey(regenerated), + ) + } + private fun image( status: String, retryable: Boolean?, url: String? = null, + updatedAt: String? = null, ): ChapterImageDto { return ChapterImageDto( index = 0, @@ -45,7 +101,7 @@ class MemoirInlineImageStateTest { size = "1024x1024", error = null, created_at = null, - updated_at = null, + updated_at = updatedAt, ) } }