diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d6f859..d4db68f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,28 +62,8 @@ jobs: run: | compose=(docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml) "${compose[@]}" ps - for service in caddy web api postgres-pgvector; do - container=$("${compose[@]}" ps -q "$service") - test -n "$container" - test "$(docker inspect -f '{{.State.Health.Status}}' "$container")" = healthy - done - test "$(docker inspect -f '{{.HostConfig.Memory}}' $("${compose[@]}" ps -q caddy))" = 67108864 - test "$(docker inspect -f '{{.HostConfig.Memory}}' $("${compose[@]}" ps -q web))" = 335544320 - test "$(docker inspect -f '{{.HostConfig.Memory}}' $("${compose[@]}" ps -q api))" = 671088640 - test "$(docker inspect -f '{{.HostConfig.Memory}}' $("${compose[@]}" ps -q postgres-pgvector))" = 503316480 - test "$(docker inspect -f '{{.HostConfig.NanoCpus}}' $("${compose[@]}" ps -q caddy))" = 200000000 - test "$(docker inspect -f '{{.HostConfig.NanoCpus}}' $("${compose[@]}" ps -q web))" = 600000000 - test "$(docker inspect -f '{{.HostConfig.NanoCpus}}' $("${compose[@]}" ps -q api))" = 1250000000 - test "$(docker inspect -f '{{.HostConfig.NanoCpus}}' $("${compose[@]}" ps -q postgres-pgvector))" = 800000000 - test "$(docker inspect -f '{{.HostConfig.PidsLimit}}' $("${compose[@]}" ps -q caddy))" = 64 - test "$(docker inspect -f '{{.HostConfig.PidsLimit}}' $("${compose[@]}" ps -q web))" = 128 - test "$(docker inspect -f '{{.HostConfig.PidsLimit}}' $("${compose[@]}" ps -q api))" = 256 - test "$(docker inspect -f '{{.HostConfig.PidsLimit}}' $("${compose[@]}" ps -q postgres-pgvector))" = 128 - test -n "$(docker port $("${compose[@]}" ps -q caddy) 80/tcp)" - test -n "$(docker port $("${compose[@]}" ps -q caddy) 443/tcp)" - test -z "$(docker port $("${compose[@]}" ps -q web))" - test -z "$(docker port $("${compose[@]}" ps -q api))" - test -z "$(docker port $("${compose[@]}" ps -q postgres-pgvector))" + project=$("${compose[@]}" config --format json | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>console.log(JSON.parse(s).name))') + "${compose[@]}" config --format json | node scripts/verify-compose-resources.mjs - "$project" - name: Smoke test env: HAOBLOG_BASE_URL: http://localhost diff --git a/AGENTS.md b/AGENTS.md index 1cba691..a6ed5f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ The current repository uses one root pnpm workspace and one Maven application. N - Compose validation using placeholder environment values: `docker compose --env-file .env.example -f infra/compose/compose.dev.yml config` - Production Compose validation: `docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml config` - Compose resource and health-boundary verification: `pnpm compose:verify` +- Running project verification (task-only environment/override): pipe `docker compose -p --env-file -f infra/compose/compose.prod.yml -f config --format json` to `node scripts/verify-compose-resources.mjs - `; reads allowlisted runtime fields and cgroup v2, never full inspect environment. - Local database startup: `docker compose --env-file .env -f infra/compose/compose.dev.yml up -d` The Maven Wrapper is `apps/api/mvnw` / `apps/api/mvnw.cmd`, with Maven distribution `3.9.11`. The pnpm version is pinned as `pnpm@11.16.0` in the root `package.json`; the workspace also permits the explicitly configured `esbuild` build script. diff --git a/README.md b/README.md index aea68ff..7304ff2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ HaoBlog 是一个面向开发者的低成本个人博客与数字花园,采用 Java 21、Spring Boot、Nuxt 4、Vue 3 和 PostgreSQL/pgvector 构建。项目使用模块化单体架构,公开站点以服务端渲染为核心,管理后台通过 `/studio` 提供内容运营能力。 -项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录、结构化数据、知识星图、安全终端、双主题、可关闭的首页 Three.js、音乐频谱、404 信号修复和显式准备的 PWA 离线工具箱。公开内容保持 SSR;无 JavaScript、Save-Data、reduced-motion、触摸和重型能力失败时均保留可读路径。 +项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、即时评论、工具箱、RSS、Sitemap、目录、结构化数据、知识星图、安全终端、双主题、可关闭的首页 Three.js、音乐控制台与频谱、404 游戏中心和显式准备的 PWA 离线工具箱。公开内容保持 SSR;无 JavaScript、Save-Data、reduced-motion、触摸和重型能力失败时均保留可读路径。 ## 本地验证 @@ -23,6 +23,8 @@ corepack pnpm web:budget corepack pnpm compose:verify ``` -阶段五音乐默认关闭。`HAOBLOG_MUSIC_MANIFEST_URL` 在生产环境必须是绝对 HTTPS 地址;音频不经过 Spring Boot。PWA 只在 production build 或显式测试模式注册,进入 `/tools` 后还需点击“准备离线工具”并二次确认;缓存不包含 API、Studio、文章、评论、图谱、音乐或外部资源。 +阶段五音乐默认关闭。清单 URL 可在 Studio 配置,`HAOBLOG_MUSIC_MANIFEST_URL` 是数据库未配置时的回退值;生产环境必须使用绝对 HTTPS,并为浏览器直连清单与音频配置 CORS。音频不经过 Spring Boot。PWA 只在 production build 或显式测试模式注册,进入 `/tools` 后还需点击“准备离线工具”并二次确认;缓存不包含 API、Studio、文章、评论、图谱、音乐或外部资源。 完整开发环境、独立生产 Compose 验收、Lighthouse 串行路由和清理命令见 [本地启动指南](docs/本地启动指南.md)。阶段目标任务和准入清单属于本地私有验收资料,不纳入公开仓库。本地收口不等于远端 CI、真实 OSS/CDN 与 CORS、真实域名/HTTPS 安装、部署备案或阶段六 30 分钟 2GB 压测通过。 + +生产 API/Web 使用只读根目录和 32/16 MiB 临时目录,四容器禁用容器 Swap,保留原内存/CPU/PID 预算。业务连接获取等待 2 秒、SQL 3 秒、锁等待 1 秒;Flyway 使用独立连接。SMTP/OSS 在数据库事务外执行,评论通知逐条认领、最多 5 次尝试,采用至少一次语义。运行态资源验证与权限例外见启动指南。 diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java index d2ad7ed..4e703c6 100644 --- a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationMailer.java @@ -8,6 +8,9 @@ import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.util.UUID; @@ -20,19 +23,31 @@ public class CommentNotificationMailer { private final SiteService site; private final CommentNotificationProperties properties; private final JavaMailSender mailSender; + private final TransactionTemplate transaction; public CommentNotificationMailer(CommentRepository comments, ArticleCommentLookup articles, SiteService site, CommentNotificationProperties properties, - JavaMailSender mailSender) { + JavaMailSender mailSender, PlatformTransactionManager transactionManager) { this.comments = comments; this.articles = articles; this.site = site; this.properties = properties; this.mailSender = mailSender; + this.transaction = new TransactionTemplate(transactionManager); + this.transaction.setReadOnly(true); } - @Transactional(readOnly = true) + @Transactional(propagation = Propagation.NEVER) public void send(UUID commentId) { + SimpleMailMessage message = transaction.execute(status -> prepare(commentId)); + try { + mailSender.send(message); + } catch (Exception ignored) { + throw new CommentNotificationException(); + } + } + + private SimpleMailMessage prepare(UUID commentId) { Comment comment = comments.findById(commentId).orElseThrow(CommentNotificationException::new); ArticleCommentLookup.NotificationArticle article = articles .findCommentNotificationArticle(comment.getArticleId()) @@ -44,18 +59,14 @@ public void send(UUID commentId) { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(recipient); message.setFrom(from); - message.setSubject("HaoBlog 新评论待审核"); - message.setText("有一条评论待审核\n\n" + message.setSubject("HaoBlog 新评论已发布"); + message.setText("有一条评论已即时发布,请按需管理\n\n" + "昵称:" + comment.getNickname() + "\n" + "文章:" + article.title() + "\n" + "时间:" + comment.getCreatedAt() + "\n" + "正文摘要:" + summarize(comment.getContent()) + "\n" - + "Studio 审核链接:" + studioUrl + "\n"); - try { - mailSender.send(message); - } catch (Exception ignored) { - throw new CommentNotificationException(); - } + + "Studio 管理链接:" + studioUrl + "\n"); + return message; } private static String required(String value) { diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java index 8c42f66..80df49d 100644 --- a/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentNotificationOutboxProcessor.java @@ -26,7 +26,7 @@ public CommentNotificationOutboxProcessor(OutboxEventStateService state, Comment } @Scheduled( - fixedDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_FIXED_DELAY_MS:60000}", + fixedDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_FIXED_DELAY_MS:1000}", initialDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_INITIAL_DELAY_MS:5000}" ) public void processDueBatch() { @@ -41,12 +41,12 @@ private void processOne(OutboxEvent event) { if (properties.isEnabled()) { mailer.send(event.getAggregateId()); } - state.markProcessed(event.getId()); + state.markProcessed(event.getId(), event.getAttemptCount()); LOG.info("评论通知已处理 eventId={} commentId={} traceId={}", event.getId(), event.getAggregateId(), traceId); } catch (Exception ignored) { try { - state.markFailedOrRetry(event.getId()); + state.markFailedOrRetry(event.getId(), event.getAttemptCount()); } catch (Exception stateFailure) { LOG.warn("评论通知状态更新失败 eventId={} commentId={} traceId={}", event.getId(), event.getAggregateId(), traceId); diff --git a/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java b/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java index d7e8799..d9be832 100644 --- a/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java +++ b/apps/api/src/main/java/io/haoblog/comment/application/CommentService.java @@ -212,7 +212,7 @@ public CreateResult create(String slug, CreateCommand command, String visitorCoo if ((honeypot != null && !honeypot.isBlank()) || (command.website() != null && !command.website().isBlank())) { RateDecision rate = rate(visitorCookie, remoteAddress); if (!rate.allowed()) throw new CommentRateLimitException(rate.retryAfterSeconds()); - return new CreateResult(null, CommentStatus.PENDING, clock.instant(), null); + return new CreateResult(null, CommentStatus.APPROVED, clock.instant(), null); } UUID parentId = command.parentId(); @@ -246,7 +246,7 @@ public CreateResult create(String slug, CreateCommand command, String visitorCoo "articleId", target.articleId().toString(), "eventType", "COMMENT_CREATED", "occurredAt", now.toString()), now, now)); - return new CreateResult(commentId, CommentStatus.PENDING, now, deleteToken); + return new CreateResult(commentId, comment.getStatus(), now, deleteToken); } @Transactional diff --git a/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java index 126fd8a..403e635 100644 --- a/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java +++ b/apps/api/src/main/java/io/haoblog/comment/domain/Comment.java @@ -35,7 +35,7 @@ public class Comment { private String content; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 16) - private CommentStatus status = CommentStatus.PENDING; + private CommentStatus status = CommentStatus.APPROVED; @Column(name = "ip_hmac", nullable = false) private byte[] ipHmac; @Column(name = "ip_hmac_date", nullable = false) diff --git a/apps/api/src/main/java/io/haoblog/comment/infrastructure/DeadlineSmtpSocketFactory.java b/apps/api/src/main/java/io/haoblog/comment/infrastructure/DeadlineSmtpSocketFactory.java new file mode 100644 index 0000000..ebebd79 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/comment/infrastructure/DeadlineSmtpSocketFactory.java @@ -0,0 +1,59 @@ +package io.haoblog.comment.infrastructure; + +import javax.net.SocketFactory; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Timer; +import java.util.TimerTask; + +/** 单封邮件最多占用套接字 15 秒;关闭真实连接,防止慢速 SMTP 持续占用调度线程。 */ +public final class DeadlineSmtpSocketFactory extends SocketFactory { + private static final Timer DEADLINES = new Timer("smtp-deadline", true); + + public static SocketFactory getDefault() { return new DeadlineSmtpSocketFactory(); } + + @Override + public Socket createSocket() { + return new Socket() { + private final TimerTask deadline = new TimerTask() { + @Override public void run() { + try { close(); } catch (IOException ignored) { } + } + }; + { DEADLINES.schedule(deadline, 15_000); } + + @Override public synchronized void close() throws IOException { + deadline.cancel(); + DEADLINES.purge(); + super.close(); + } + }; + } + + private Socket connect(InetSocketAddress remote, InetSocketAddress local) throws IOException { + Socket socket = createSocket(); + try { + if (local != null) socket.bind(local); + socket.connect(remote, 5000); + return socket; + } catch (IOException | RuntimeException exception) { + socket.close(); + throw exception; + } + } + + @Override public Socket createSocket(String host, int port) throws IOException { + return connect(new InetSocketAddress(host, port), null); + } + @Override public Socket createSocket(InetAddress host, int port) throws IOException { + return connect(new InetSocketAddress(host, port), null); + } + @Override public Socket createSocket(String host, int port, InetAddress local, int localPort) throws IOException { + return connect(new InetSocketAddress(host, port), new InetSocketAddress(local, localPort)); + } + @Override public Socket createSocket(InetAddress host, int port, InetAddress local, int localPort) throws IOException { + return connect(new InetSocketAddress(host, port), new InetSocketAddress(local, localPort)); + } +} diff --git a/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java index d809e71..e7a904e 100644 --- a/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java +++ b/apps/api/src/main/java/io/haoblog/comment/web/PublicCommentController.java @@ -53,7 +53,7 @@ public ResponseEntity create(@PathVariable String slug, new CommentService.CreateCommand(request.nickname(), request.email(), request.content(), request.parentId(), request.challenge(), request.honeypot(), request.website()), visitorCookie(httpRequest), httpRequest.getRemoteAddr()); - return ResponseEntity.status(HttpStatus.ACCEPTED) + return ResponseEntity.status(HttpStatus.CREATED) .body(new SubmissionResponse(result.id(), result.status().name(), result.createdAt(), result.deleteToken())); } diff --git a/apps/api/src/main/java/io/haoblog/media/application/MediaUploadService.java b/apps/api/src/main/java/io/haoblog/media/application/MediaUploadService.java index 44583b1..8cc2316 100644 --- a/apps/api/src/main/java/io/haoblog/media/application/MediaUploadService.java +++ b/apps/api/src/main/java/io/haoblog/media/application/MediaUploadService.java @@ -10,6 +10,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Clock; import java.time.Instant; @@ -33,16 +36,18 @@ public class MediaUploadService { private final MediaReferenceQuery references; private final AliyunOssProperties properties; private final Clock clock; + private final TransactionTemplate transaction; public MediaUploadService(MediaUploadRepository uploads, MediaAssetRepository assets, ObjectStorage storage, MediaReferenceQuery references, - AliyunOssProperties properties, Clock clock) { + AliyunOssProperties properties, Clock clock, PlatformTransactionManager transactionManager) { this.uploads = uploads; this.assets = assets; this.storage = storage; this.references = references; this.properties = properties; this.clock = clock; + this.transaction = new TransactionTemplate(transactionManager); } @Transactional @@ -66,24 +71,38 @@ public UploadStarted start(String mimeType, long sizeBytes, int width, int heigh return new UploadStarted(id, objectKey, grant.uploadUrl(), grant.fields(), grant.expiresAt()); } - @Transactional + @Transactional(propagation = Propagation.NEVER) public MediaAsset complete(UUID uploadId) { - MediaUpload upload = uploads.findByIdForUpdate(uploadId) - .orElseThrow(() -> problem("MEDIA_UPLOAD_NOT_FOUND", "Upload intent not found", "The upload intent does not exist")); - if (upload.getCompletedMediaId() != null) { - return assets.findById(upload.getCompletedMediaId()) - .orElseThrow(() -> problem("MEDIA_ASSET_NOT_FOUND", "Media asset not found", "The completed media asset is unavailable")); - } - Instant now = Instant.now(clock); - if (!now.isBefore(upload.getExpiresAt())) { - throw problem("MEDIA_UPLOAD_EXPIRED", "Upload intent expired", "Request a new upload intent"); - } + MediaUpload upload = transaction.execute(status -> uploads.findByIdForUpdate(uploadId) + .orElseThrow(() -> problem("MEDIA_UPLOAD_NOT_FOUND", "Upload intent not found", "The upload intent does not exist"))); + if (upload.getCompletedMediaId() != null) return completed(upload); + checkExpiry(upload); ObjectStorage.StoredObject stored; try { stored = storage.head(upload.getObjectKey()); } catch (ObjectStorageException exception) { throw storageUnavailable(); } + return transaction.execute(status -> finish(uploadId, stored)); + } + + private MediaAsset completed(MediaUpload upload) { + return assets.findById(upload.getCompletedMediaId()) + .orElseThrow(() -> problem("MEDIA_ASSET_NOT_FOUND", "Media asset not found", "The completed media asset is unavailable")); + } + + private void checkExpiry(MediaUpload upload) { + if (!clock.instant().isBefore(upload.getExpiresAt())) { + throw problem("MEDIA_UPLOAD_EXPIRED", "Upload intent expired", "Request a new upload intent"); + } + } + + private MediaAsset finish(UUID uploadId, ObjectStorage.StoredObject stored) { + MediaUpload upload = uploads.findByIdForUpdate(uploadId) + .orElseThrow(() -> problem("MEDIA_UPLOAD_NOT_FOUND", "Upload intent not found", "The upload intent does not exist")); + if (upload.getCompletedMediaId() != null) return completed(upload); + checkExpiry(upload); + Instant now = Instant.now(clock); if (stored == null) { throw problem("MEDIA_OBJECT_NOT_FOUND", "Uploaded object not found", "Upload the object before confirming it"); } diff --git a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java index 32469be..ed81ad7 100644 --- a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java +++ b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventRepository.java @@ -12,6 +12,10 @@ import java.util.UUID; public interface OutboxEventRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select e from OutboxEvent e where e.id = :id") + java.util.Optional findByIdForUpdate(@Param("id") UUID id); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" select e from OutboxEvent e diff --git a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java index e01441d..a2dd985 100644 --- a/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java +++ b/apps/api/src/main/java/io/haoblog/shared/outbox/OutboxEventStateService.java @@ -13,7 +13,7 @@ @Service public class OutboxEventStateService { public static final int MAX_ATTEMPTS = 5; - public static final int BATCH_SIZE = 10; + public static final int BATCH_SIZE = 1; private static final Duration PROCESSING_LEASE = Duration.ofSeconds(60); private final OutboxEventRepository repository; @@ -30,23 +30,29 @@ public List claimCommentCreatedBatch() { List events = repository.findAvailable("COMMENT_CREATED", List.of(OutboxStatus.PENDING, OutboxStatus.PROCESSING), now, PageRequest.of(0, BATCH_SIZE)); - events.forEach(event -> event.claim(now, now.plus(PROCESSING_LEASE))); - return List.copyOf(events); + return events.stream().filter(event -> { + if (event.getAttemptCount() >= MAX_ATTEMPTS) { + event.retryOrFail(now, now, MAX_ATTEMPTS); + return false; + } + event.claim(now, now.plus(PROCESSING_LEASE)); + return true; + }).toList(); } @Transactional - public void markProcessed(UUID eventId) { - repository.findById(eventId).ifPresent(event -> { - if (event.getStatus() == OutboxStatus.PROCESSING) { + public void markProcessed(UUID eventId, int attempt) { + repository.findByIdForUpdate(eventId).ifPresent(event -> { + if (event.getStatus() == OutboxStatus.PROCESSING && event.getAttemptCount() == attempt) { event.markProcessed(clock.instant()); } }); } @Transactional - public void markFailedOrRetry(UUID eventId) { - repository.findById(eventId).ifPresent(event -> { - if (event.getStatus() != OutboxStatus.PROCESSING) return; + public void markFailedOrRetry(UUID eventId, int attempt) { + repository.findByIdForUpdate(eventId).ifPresent(event -> { + if (event.getStatus() != OutboxStatus.PROCESSING || event.getAttemptCount() != attempt) return; Instant now = clock.instant(); event.retryOrFail(now, now.plus(retryDelay(event.getAttemptCount())), MAX_ATTEMPTS); }); diff --git a/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java b/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java index 82b203a..d990ddf 100644 --- a/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java +++ b/apps/api/src/main/java/io/haoblog/shared/web/GlobalExceptionHandler.java @@ -55,6 +55,17 @@ ResponseEntity optimisticLock(OptimisticLockingFailureException return problemResponseWriter.response(HttpStatus.CONFLICT, "ARTICLE_VERSION_CONFLICT", "Article version conflict", "Reload the latest article before saving"); } + @ExceptionHandler({org.springframework.dao.QueryTimeoutException.class, + org.springframework.dao.PessimisticLockingFailureException.class, + org.springframework.dao.DataAccessResourceFailureException.class, + org.springframework.transaction.CannotCreateTransactionException.class}) + ResponseEntity databaseUnavailable(Exception exception) { + var response = problemResponseWriter.response(HttpStatus.SERVICE_UNAVAILABLE, + "DATABASE_BUSY", "Database temporarily unavailable", "Retry later; check saved state before repeating a write"); + return ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()) + .header("Retry-After", "2").header("Cache-Control", "no-store").body(response.getBody()); + } + @ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class}) ResponseEntity notFound(NoResourceFoundException exception) { return problemResponseWriter.response(HttpStatus.NOT_FOUND, "NOT_FOUND", "Resource not found", null); diff --git a/apps/api/src/main/java/io/haoblog/site/application/SiteService.java b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java index 1251662..6f118b6 100644 --- a/apps/api/src/main/java/io/haoblog/site/application/SiteService.java +++ b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java @@ -15,7 +15,8 @@ public class SiteService { private final SiteSettingRepository repository; private final String publicBaseUrl; private final String authorName; - private final String musicManifestUrl; + private final String fallbackMusicManifestUrl; + private final String activeProfiles; @Autowired public SiteService(SiteSettingRepository repository, @@ -23,37 +24,34 @@ public SiteService(SiteSettingRepository repository, @Value("${haoblog.site.author-name}") String authorName, @Value("${haoblog.site.music-manifest-url:}") String musicManifestUrl, @Value("${spring.profiles.active:local}") String activeProfiles) { - this(repository, publicBaseUrl, authorName, musicManifestUrl, activeProfiles, true); - } - - public SiteService(SiteSettingRepository repository, String publicBaseUrl, String authorName) { - this(repository, publicBaseUrl, authorName, null, "local", false); - } - - private SiteService(SiteSettingRepository repository, String publicBaseUrl, String authorName, - String musicManifestUrl, String activeProfiles, boolean validateManifest) { this.repository = repository; this.publicBaseUrl = normalizePublicBaseUrl(publicBaseUrl); if (authorName == null || authorName.isBlank()) { throw new IllegalArgumentException("HAOBLOG_AUTHOR_NAME must not be blank"); } this.authorName = authorName.trim(); - this.musicManifestUrl = validateManifest - ? normalizeMusicManifestUrl(musicManifestUrl, activeProfiles) - : null; + this.activeProfiles = activeProfiles == null ? "" : activeProfiles; + this.fallbackMusicManifestUrl = normalizeMusicManifestUrl(musicManifestUrl, this.activeProfiles); + } + + public SiteService(SiteSettingRepository repository, String publicBaseUrl, String authorName) { + this(repository, publicBaseUrl, authorName, null, "local"); } public SiteResult get() { var setting = repository.findBySiteKey("default").orElseThrow(); + String manifestUrl = effectiveMusicManifestUrl(setting); return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, - setting.isCommentsEnabled(), setting.isMusicEnabled() && musicManifestUrl != null, + setting.isCommentsEnabled(), setting.isMusicEnabled() && manifestUrl != null, + setting.isMusicEnabled() ? manifestUrl : null, setting.isThreeDEnabled()); } public AdminSiteResult getAdmin() { var setting = repository.findBySiteKey("default").orElseThrow(); return new AdminSiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, - setting.isCommentsEnabled(), setting.isMusicEnabled(), setting.isThreeDEnabled(), setting.getVersion()); + setting.isCommentsEnabled(), setting.isMusicEnabled(), effectiveMusicManifestUrl(setting), + setting.isThreeDEnabled(), setting.getVersion()); } @org.springframework.transaction.annotation.Transactional @@ -64,22 +62,43 @@ public AdminSiteResult updateCommentsEnabled(long expectedVersion, boolean comme @org.springframework.transaction.annotation.Transactional public AdminSiteResult updateSettings(long expectedVersion, Boolean commentsEnabled, Boolean musicEnabled, Boolean threeDEnabled) { + return updateSettings(expectedVersion, commentsEnabled, musicEnabled, threeDEnabled, null, false); + } + + @org.springframework.transaction.annotation.Transactional + public AdminSiteResult updateSettings(long expectedVersion, Boolean commentsEnabled, + Boolean musicEnabled, Boolean threeDEnabled, String musicManifestUrl) { + return updateSettings(expectedVersion, commentsEnabled, musicEnabled, threeDEnabled, musicManifestUrl, true); + } + + private AdminSiteResult updateSettings(long expectedVersion, Boolean commentsEnabled, + Boolean musicEnabled, Boolean threeDEnabled, + String musicManifestUrl, boolean updateManifest) { var setting = repository.findBySiteKey("default").orElseThrow(); if (setting.getVersion() != expectedVersion) { throw new io.haoblog.shared.web.ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", "Reload the latest site settings before saving", setting.getVersion()); } + if (updateManifest) { + try { + setting.setMusicManifestUrl(normalizeMusicManifestUrl(musicManifestUrl, activeProfiles)); + } catch (IllegalArgumentException exception) { + throw new io.haoblog.shared.web.ProblemException("MUSIC_MANIFEST_URL_INVALID", + "Invalid music manifest URL", exception.getMessage()); + } + } boolean nextMusicEnabled = musicEnabled == null ? setting.isMusicEnabled() : musicEnabled; - if (nextMusicEnabled && musicManifestUrl == null) { + if (nextMusicEnabled && effectiveMusicManifestUrl(setting) == null) { throw new io.haoblog.shared.web.ProblemException("MUSIC_MANIFEST_NOT_CONFIGURED", - "Music manifest is not configured", "Configure HAOBLOG_MUSIC_MANIFEST_URL before enabling music"); + "Music manifest is not configured", "Configure a music manifest URL before enabling music"); } if (commentsEnabled != null) setting.setCommentsEnabled(commentsEnabled); setting.setMusicEnabled(nextMusicEnabled); if (threeDEnabled != null) setting.setThreeDEnabled(threeDEnabled); var saved = repository.saveAndFlush(setting); return new AdminSiteResult(saved.getTitle(), saved.getDescription(), publicBaseUrl, authorName, - saved.isCommentsEnabled(), saved.isMusicEnabled(), saved.isThreeDEnabled(), saved.getVersion()); + saved.isCommentsEnabled(), saved.isMusicEnabled(), effectiveMusicManifestUrl(saved), + saved.isThreeDEnabled(), saved.getVersion()); } public long currentVersion() { @@ -128,25 +147,31 @@ static String normalizeMusicManifestUrl(String raw, String activeProfiles) { return value; } - private static final java.util.Set SetOfLocalHosts = java.util.Set.of("localhost", "127.0.0.1", "::1"); + private static final java.util.Set SetOfLocalHosts = java.util.Set.of("localhost", "127.0.0.1", "::1", "[::1]"); + + private String effectiveMusicManifestUrl(io.haoblog.site.domain.SiteSetting setting) { + return setting.getMusicManifestUrl() == null ? fallbackMusicManifestUrl : setting.getMusicManifestUrl(); + } public record SiteResult(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled) { + boolean commentsEnabled, boolean musicEnabled, String musicManifestUrl, + boolean threeDEnabled) { public SiteResult(String title, String description, String siteUrl, String authorName) { - this(title, description, siteUrl, authorName, true, false, false); + this(title, description, siteUrl, authorName, true, false, null, false); } public SiteResult(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { - this(title, description, siteUrl, authorName, commentsEnabled, false, false); + this(title, description, siteUrl, authorName, commentsEnabled, false, null, false); } } public record AdminSiteResult(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, + boolean commentsEnabled, boolean musicEnabled, String musicManifestUrl, + boolean threeDEnabled, long version) { public AdminSiteResult(String title, String description, String siteUrl, String authorName, boolean commentsEnabled, long version) { - this(title, description, siteUrl, authorName, commentsEnabled, false, false, version); + this(title, description, siteUrl, authorName, commentsEnabled, false, null, false, version); } } } diff --git a/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java b/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java index 777b822..2159da0 100644 --- a/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java +++ b/apps/api/src/main/java/io/haoblog/site/domain/SiteSetting.java @@ -13,6 +13,7 @@ public class SiteSetting { @Column(nullable = false, length = 600) private String description; @Column(name = "comments_enabled", nullable = false) private boolean commentsEnabled = true; @Column(name = "music_enabled", nullable = false) private boolean musicEnabled = false; + @Column(name = "music_manifest_url", length = 2048) private String musicManifestUrl; @Column(name = "three_d_enabled", nullable = false) private boolean threeDEnabled = false; @Version @Column(nullable = false) private long version; protected SiteSetting() {} @@ -23,9 +24,11 @@ public SiteSetting(String siteKey, String title, String description) { public String getDescription() { return description; } public boolean isCommentsEnabled() { return commentsEnabled; } public boolean isMusicEnabled() { return musicEnabled; } + public String getMusicManifestUrl() { return musicManifestUrl; } public boolean isThreeDEnabled() { return threeDEnabled; } public long getVersion() { return version; } public void setCommentsEnabled(boolean commentsEnabled) { this.commentsEnabled = commentsEnabled; } public void setMusicEnabled(boolean musicEnabled) { this.musicEnabled = musicEnabled; } + public void setMusicManifestUrl(String musicManifestUrl) { this.musicManifestUrl = musicManifestUrl; } public void setThreeDEnabled(boolean threeDEnabled) { this.threeDEnabled = threeDEnabled; } } diff --git a/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java b/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java index 2bb877e..31ecbe6 100644 --- a/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java +++ b/apps/api/src/main/java/io/haoblog/site/web/AdminSiteController.java @@ -29,7 +29,7 @@ public Response get() { public Response update(@RequestBody @Valid UpdateRequest request) { try { return Response.from(service.updateSettings(request.version(), request.commentsEnabled(), - request.musicEnabled(), request.threeDEnabled())); + request.musicEnabled(), request.threeDEnabled(), request.musicManifestUrl())); } catch (OptimisticLockingFailureException exception) { throw new ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", "Reload the latest site settings before saving", service.currentVersion()); @@ -37,13 +37,15 @@ public Response update(@RequestBody @Valid UpdateRequest request) { } public record UpdateRequest(@NotNull Long version, @NotNull Boolean commentsEnabled, - Boolean musicEnabled, Boolean threeDEnabled) {} + Boolean musicEnabled, Boolean threeDEnabled, String musicManifestUrl) {} public record Response(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, long version) { + boolean commentsEnabled, boolean musicEnabled, String musicManifestUrl, + boolean threeDEnabled, long version) { static Response from(SiteService.AdminSiteResult result) { return new Response(result.title(), result.description(), result.siteUrl(), result.authorName(), - result.commentsEnabled(), result.musicEnabled(), result.threeDEnabled(), result.version()); + result.commentsEnabled(), result.musicEnabled(), result.musicManifestUrl(), + result.threeDEnabled(), result.version()); } } } diff --git a/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java b/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java index d9ff3b7..a7e17a1 100644 --- a/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java +++ b/apps/api/src/main/java/io/haoblog/site/web/PublicSiteController.java @@ -21,7 +21,7 @@ public class PublicSiteController { public ResponseEntity site(@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { var result = service.get(); var response = new SiteResponse(result.title(), result.description(), result.siteUrl(), result.authorName(), - result.commentsEnabled(), result.musicEnabled(), result.threeDEnabled()); + result.commentsEnabled(), result.musicEnabled(), result.musicManifestUrl(), result.threeDEnabled()); var headers = new HttpHeaders(); headers.setETag(representationHash(response)); headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); @@ -41,13 +41,14 @@ private static String representationHash(Object value) { } public record SiteResponse(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled) { + boolean commentsEnabled, boolean musicEnabled, String musicManifestUrl, + boolean threeDEnabled) { public SiteResponse(String title, String description, String siteUrl, String authorName) { - this(title, description, siteUrl, authorName, true, false, false); + this(title, description, siteUrl, authorName, true, false, null, false); } public SiteResponse(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { - this(title, description, siteUrl, authorName, commentsEnabled, false, false); + this(title, description, siteUrl, authorName, commentsEnabled, false, null, false); } } } diff --git a/apps/api/src/main/resources/application.yml b/apps/api/src/main/resources/application.yml index 63aea3a..a33b7d7 100644 --- a/apps/api/src/main/resources/application.yml +++ b/apps/api/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + lifecycle: + timeout-per-shutdown-phase: 20s application: name: haoblog-api datasource: @@ -8,6 +10,13 @@ spring: hikari: maximum-pool-size: ${DB_POOL_MAXIMUM_SIZE:6} minimum-idle: ${DB_POOL_MINIMUM_IDLE:1} + connection-timeout: 2000 + validation-timeout: 1000 + connection-init-sql: SET statement_timeout = '3s'; SET lock_timeout = '1s' + data-source-properties: + connectTimeout: 2 + socketTimeout: 5 + cancelSignalTimeout: 1 mail: host: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_HOST:} port: ${HAOBLOG_COMMENT_NOTIFICATION_SMTP_PORT:25} @@ -19,6 +28,9 @@ spring: mail.smtp.connectiontimeout: 5000 mail.smtp.timeout: 3000 mail.smtp.writetimeout: 5000 + mail.smtp.quitwait: false + mail.smtp.socketFactory.class: io.haoblog.comment.infrastructure.DeadlineSmtpSocketFactory + mail.smtp.socketFactory.fallback: false jpa: open-in-view: false hibernate: @@ -29,6 +41,10 @@ spring: size: 1 flyway: enabled: true + # 使用独立迁移连接,不能继承业务连接的短 SQL 超时。 + url: ${spring.datasource.url} + user: ${spring.datasource.username} + password: ${spring.datasource.password} placeholders: admin_username: ${HAOBLOG_ADMIN_USERNAME} admin_password_hash: ${HAOBLOG_ADMIN_PASSWORD_HASH} @@ -54,6 +70,7 @@ management: mail: enabled: ${HAOBLOG_COMMENT_NOTIFICATION_ENABLED:false} server: + shutdown: graceful port: ${API_PORT:8080} tomcat: threads: diff --git a/apps/api/src/main/resources/db/migration/V17__instant_comments_and_music_manifest.sql b/apps/api/src/main/resources/db/migration/V17__instant_comments_and_music_manifest.sql new file mode 100644 index 0000000..dc5acfa --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V17__instant_comments_and_music_manifest.sql @@ -0,0 +1,3 @@ +ALTER TABLE comment ALTER COLUMN status SET DEFAULT 'APPROVED'; + +ALTER TABLE site_setting ADD COLUMN music_manifest_url varchar(2048); diff --git a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java index f13b69b..6d48213 100644 --- a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java +++ b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java @@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/AdminContentIT.java b/apps/api/src/test/java/io/haoblog/AdminContentIT.java index cb4540d..6e8d276 100644 --- a/apps/api/src/test/java/io/haoblog/AdminContentIT.java +++ b/apps/api/src/test/java/io/haoblog/AdminContentIT.java @@ -37,6 +37,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/AdminSessionIT.java b/apps/api/src/test/java/io/haoblog/AdminSessionIT.java index efdbe7f..be12def 100644 --- a/apps/api/src/test/java/io/haoblog/AdminSessionIT.java +++ b/apps/api/src/test/java/io/haoblog/AdminSessionIT.java @@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.junit.jupiter.api.Assertions.assertEquals; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/ArticleWorkflowIT.java b/apps/api/src/test/java/io/haoblog/ArticleWorkflowIT.java index 80caa82..b3fa44d 100644 --- a/apps/api/src/test/java/io/haoblog/ArticleWorkflowIT.java +++ b/apps/api/src/test/java/io/haoblog/ArticleWorkflowIT.java @@ -38,6 +38,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index c9c14d2..9121435 100644 --- a/apps/api/src/test/java/io/haoblog/ContentModelIT.java +++ b/apps/api/src/test/java/io/haoblog/ContentModelIT.java @@ -35,6 +35,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest class ContentModelIT { @@ -57,7 +58,7 @@ static void database(DynamicPropertyRegistry registry) { @Test void migratesAllVersionsAndCreatesContentTables() { - assertEquals(16, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(17, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); for (String table : List.of("article", "category", "tag", "article_tag", "article_revision", "article_preview_token", "media_asset", "media_upload", "outbox_event", "comment")) { assertEquals(1, jdbc.queryForObject( @@ -243,7 +244,7 @@ void commentConstraintsStatusParentAndUuidAreEnforced() { jdbc.update("INSERT INTO comment(id, article_id, nickname, content, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", parentId, articleId, "Hao", "parent", digest, now.atZone(ZoneOffset.UTC).toLocalDate(), digest, digest, timestamp, timestamp); - assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM comment WHERE id=?", String.class, parentId)); + assertEquals("APPROVED", jdbc.queryForObject("SELECT status FROM comment WHERE id=?", String.class, parentId)); assertThrows(DataAccessException.class, () -> jdbc.update( "INSERT INTO comment(id, article_id, nickname, content, status, ip_hmac, ip_hmac_date, content_fingerprint, delete_token_digest, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'UNKNOWN', ?, ?, ?, ?, ?, ?)", @@ -274,7 +275,7 @@ void commentStatusPersistsAndOptimisticLockRejectsStaleUpdate() { Comment saved = comments.saveAndFlush(new Comment(article.getId(), null, "Hao", null, null, null, "body", new byte[32], now.atZone(ZoneOffset.UTC).toLocalDate(), new byte[32], deleteTokenDigest, now)); assertEquals(7, saved.getId().version()); - assertEquals(CommentStatus.PENDING, saved.getStatus()); + assertEquals(CommentStatus.APPROVED, saved.getStatus()); EntityManager firstManager = entityManagerFactory.createEntityManager(); EntityManager secondManager = entityManagerFactory.createEntityManager(); var firstTransaction = firstManager.getTransaction(); diff --git a/apps/api/src/test/java/io/haoblog/GardenGraphIT.java b/apps/api/src/test/java/io/haoblog/GardenGraphIT.java index 0a2ca63..efac4b2 100644 --- a/apps/api/src/test/java/io/haoblog/GardenGraphIT.java +++ b/apps/api/src/test/java/io/haoblog/GardenGraphIT.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/MediaUploadIT.java b/apps/api/src/test/java/io/haoblog/MediaUploadIT.java index 8c465c6..da4e445 100644 --- a/apps/api/src/test/java/io/haoblog/MediaUploadIT.java +++ b/apps/api/src/test/java/io/haoblog/MediaUploadIT.java @@ -18,6 +18,9 @@ import java.time.Instant; import java.util.Map; import java.util.UUID; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -26,6 +29,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc @@ -47,11 +51,30 @@ static void database(DynamicPropertyRegistry registry) { registry.add("haoblog.media.oss.access-key-id", () -> "test-id"); registry.add("haoblog.media.oss.access-key-secret", () -> "test-key"); registry.add("haoblog.media.oss.public-base-url", () -> "https://cdn.test"); + registry.add("HAOBLOG_COMMENT_NOTIFICATION_INITIAL_DELAY_MS", () -> "3600000"); + registry.add("HAOBLOG_CONTENT_SCHEDULING_INITIAL_DELAY_MS", () -> "3600000"); + registry.add("spring.mail.host", () -> "127.0.0.1"); + registry.add("spring.mail.properties.mail.smtp.auth", () -> "false"); + registry.add("haoblog.comment.notification.recipient", () -> "owner@example.invalid"); + registry.add("haoblog.comment.notification.from", () -> "notify@example.invalid"); } @Autowired MockMvc mvc; @Autowired JdbcTemplate jdbc; @MockitoBean ObjectStorage storage; + @Autowired io.haoblog.media.application.MediaUploadService uploads; + @Autowired com.zaxxer.hikari.HikariDataSource pool; + @Autowired io.haoblog.shared.outbox.OutboxEventStateService state; + @Autowired io.haoblog.shared.outbox.OutboxEventRepository events; + @Autowired io.haoblog.comment.application.CommentNotificationMailer mailer; + @Autowired org.springframework.mail.javamail.JavaMailSenderImpl smtp; + @Autowired io.haoblog.content.persistence.ArticleRepository articles; + @Autowired io.haoblog.comment.persistence.CommentRepository comments; + @Autowired io.haoblog.content.application.ScheduledArticlePublisher publisher; + @Autowired io.haoblog.content.application.ArticleWorkflowService workflow; + @Autowired org.springframework.scheduling.TaskScheduler scheduler; + @Autowired org.flywaydb.core.Flyway flyway; + @Autowired org.springframework.session.jdbc.JdbcIndexedSessionRepository sessions; @BeforeEach void clean() { @@ -85,4 +108,176 @@ void completesUploadThroughHttpDatabaseAndStorageBoundaryIdempotently() throws E .andExpect(jsonPath("$.id").value(new tools.jackson.databind.ObjectMapper().readTree(completed.getResponse().getContentAsString()).get("id").asText())); org.junit.jupiter.api.Assertions.assertEquals(1, jdbc.queryForObject("SELECT count(*) FROM media_asset", Integer.class)); } + + private UUID intent() { + UUID id = UUID.randomUUID(); + jdbc.update("INSERT INTO media_upload(id,object_key,mime_type,size_bytes,width,height,sha256,expires_at,created_at) VALUES (?,?,'image/png',12,640,480,?,now()+interval '5 minutes',now())", + id, "media/" + id + ".png", "a".repeat(64)); + when(storage.publicUrl(anyString())).thenAnswer(i -> "https://cdn.test/" + i.getArgument(0)); + return id; + } + + private ObjectStorage.StoredObject object(String key) { + return new ObjectStorage.StoredObject(key, "image/png", 12, 640, 480, "a".repeat(64)); + } + + @Test + void slowOssReleasesConnectionsAndRowLocksAndConcurrentCompletionIsIdempotent() throws Exception { + UUID id = intent(); + CountDownLatch entered = new CountDownLatch(2), release = new CountDownLatch(1); + when(storage.head(anyString())).thenAnswer(i -> { + assertFalse(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()); + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + return object(i.getArgument(0)); + }); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> uploads.complete(id)); + var second = executor.submit(() -> uploads.complete(id)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(0, pool.getHikariPoolMXBean().getActiveConnections()); + // 实际 UPDATE 必须立即获得同一行锁,不以源码注解代替释放证明。 + assertEquals(1, jdbc.update("UPDATE media_upload SET expires_at=expires_at WHERE id=?", id)); + } finally { release.countDown(); } + assertEquals(first.get(10, TimeUnit.SECONDS).getId(), second.get(10, TimeUnit.SECONDS).getId()); + assertEquals(1, jdbc.queryForObject("SELECT count(*) FROM media_asset", Integer.class)); + } + } + + @Test + void rechecksExpiryAndAllMetadataAfterHead() { + for (String change : java.util.List.of("expires_at=now()-interval '1 second'", "object_key=object_key||'changed'", + "mime_type='image/jpeg'", "size_bytes=13", "width=641", "height=481", "sha256=repeat('b',64)")) { + UUID id = intent(); + when(storage.head(anyString())).thenAnswer(i -> { + jdbc.update("UPDATE media_upload SET " + change + " WHERE id=?", id); + return object(i.getArgument(0)); + }); + var failure = assertThrows(io.haoblog.shared.web.ProblemException.class, () -> uploads.complete(id)); + assertEquals(change.startsWith("expires") ? "MEDIA_UPLOAD_EXPIRED" : "MEDIA_METADATA_MISMATCH", failure.getCode()); + } + assertEquals(0, jdbc.queryForObject("SELECT count(*) FROM media_asset", Integer.class)); + } + + @Test + void poolQueryAndLockWaitsAreBoundedAndRecover() throws Exception { + assertEquals(6, pool.getMaximumPoolSize()); + assertEquals(1, pool.getMinimumIdle()); + assertEquals("3s", jdbc.queryForObject("SHOW statement_timeout", String.class)); + assertEquals("1s", jdbc.queryForObject("SHOW lock_timeout", String.class)); + try (var migration = flyway.getConfiguration().getDataSource().getConnection(); + var statement = migration.createStatement(); var result = statement.executeQuery("SHOW statement_timeout")) { + assertTrue(result.next()); assertEquals("0", result.getString(1)); + } + long start = System.nanoTime(); + assertThrows(org.springframework.dao.QueryTimeoutException.class, () -> jdbc.execute("SELECT pg_sleep(8)")); + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-start) < 5000); + try (var raw = java.sql.DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) { + try (var statement = raw.createStatement(); var result = statement.executeQuery("SHOW statement_timeout")) { + assertTrue(result.next()); assertEquals("0", result.getString(1)); + } + UUID id = intent(); + raw.setAutoCommit(false); + raw.createStatement().execute("SELECT id FROM media_upload WHERE id='" + id + "' FOR UPDATE"); + mvc.perform(post("/api/v1/admin/media/uploads/" + id + "/complete").with(user("admin").roles("ADMIN")).with(csrf())) + .andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("DATABASE_BUSY")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control", "no-store")); + raw.rollback(); + } + var connections = new java.util.ArrayList(); + try { + for (int i=0; i<6; i++) connections.add(pool.getConnection()); + start = System.nanoTime(); + assertThrows(java.sql.SQLTransientConnectionException.class, () -> pool.getConnection()); + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-start) < 3500); + } finally { for (var c : connections) c.close(); } + assertEquals(1, jdbc.queryForObject("SELECT 1", Integer.class)); + } + + @Test + void expiredLeasesStopAtFiveAndStaleCompletionCannotOverwriteNewClaim() { + UUID aggregate = UUID.randomUUID(); + Instant now = Instant.now(); + var event = events.saveAndFlush(new io.haoblog.shared.outbox.OutboxEvent(aggregate, "COMMENT_CREATED", + Map.of("commentId", aggregate.toString(), "articleId", aggregate.toString(), "eventType", "COMMENT_CREATED", "occurredAt", now.toString()), now.minusSeconds(5), now)); + for (int attempt=1; attempt<=5; attempt++) { + var claimed = state.claimCommentCreatedBatch(); + assertEquals(1, claimed.size()); + assertEquals(attempt, claimed.getFirst().getAttemptCount()); + if (attempt>1) { + state.markProcessed(event.getId(), attempt-1); + state.markFailedOrRetry(event.getId(), attempt-1); + assertEquals(io.haoblog.shared.outbox.OutboxStatus.PROCESSING, events.findById(event.getId()).orElseThrow().getStatus()); + } + jdbc.update("UPDATE outbox_event SET available_at=now()-interval '1 second' WHERE id=?", event.getId()); + } + assertTrue(state.claimCommentCreatedBatch().isEmpty()); + assertEquals(io.haoblog.shared.outbox.OutboxStatus.FAILED, events.findById(event.getId()).orElseThrow().getStatus()); + assertEquals(5, events.findById(event.getId()).orElseThrow().getAttemptCount()); + } + + @Test + void retryWaitsUntilAvailableAndCompletedEventsAreNotClaimed() { + UUID id = UUID.randomUUID(); + Instant now = Instant.now(); + var event = events.saveAndFlush(new io.haoblog.shared.outbox.OutboxEvent(id, "COMMENT_CREATED", + Map.of("commentId", id.toString(), "articleId", id.toString(), "eventType", "COMMENT_CREATED", "occurredAt", now.toString()), now.minusSeconds(1), now)); + assertEquals(1, state.claimCommentCreatedBatch().size()); + state.markFailedOrRetry(event.getId(), 1); + var retry = events.findById(event.getId()).orElseThrow(); + assertEquals(io.haoblog.shared.outbox.OutboxStatus.PENDING, retry.getStatus()); + assertTrue(retry.getAvailableAt().isAfter(now.plusSeconds(8))); + assertTrue(state.claimCommentCreatedBatch().isEmpty()); + jdbc.update("UPDATE outbox_event SET available_at=now()-interval '1 second' WHERE id=?", event.getId()); + assertEquals(2, state.claimCommentCreatedBatch().getFirst().getAttemptCount()); + state.markProcessed(event.getId(), 2); + assertTrue(state.claimCommentCreatedBatch().isEmpty()); + assertNotNull(events.findById(event.getId()).orElseThrow().getProcessedAt()); + } + + @Test + void tricklingSmtpIsActuallyClosedWithoutHoldingConnectionAndPublisherRunsNext() throws Exception { + Instant now = Instant.now(); + var article = articles.saveAndFlush(new io.haoblog.content.domain.Article("smtp-source", "SMTP source", "excerpt", "# body", + io.haoblog.content.domain.ArticleStatus.DRAFT, null, now)); + workflow.publish(article.getId(), article.getVersion()); + var comment = comments.saveAndFlush(new io.haoblog.comment.domain.Comment(article.getId(), null, "Synthetic", null, null, null, + "Synthetic body", new byte[32], java.time.LocalDate.now(), new byte[32], new byte[32], now)); + var due = new io.haoblog.content.domain.Article("smtp-due", "SMTP due", "excerpt", "# body", + io.haoblog.content.domain.ArticleStatus.DRAFT, null, now); + due.schedule(now.minusSeconds(5), now); + articles.saveAndFlush(due); + try (var server = new java.net.ServerSocket(0); var executor = Executors.newSingleThreadExecutor()) { + server.setSoTimeout(5000); + smtp.setPort(server.getLocalPort()); + CountDownLatch connected = new CountDownLatch(1), published = new CountDownLatch(1), failed = new CountDownLatch(1); + var fake = executor.submit(() -> { + try (var socket = server.accept()) { + connected.countDown(); + // 每 200ms 一字节:读取超时不会触发,必须由 15s 总期限关闭套接字。 + for (int i=0; i<100; i++) { socket.getOutputStream().write('2'); socket.getOutputStream().flush(); Thread.sleep(200); } + } catch (Exception expected) { } + }); + long start = System.nanoTime(); + scheduler.schedule(() -> { try { mailer.send(comment.getId()); } catch (io.haoblog.comment.application.CommentNotificationException expected) { failed.countDown(); } }, now); + assertTrue(connected.await(5, TimeUnit.SECONDS)); + assertEquals(0, pool.getHikariPoolMXBean().getActiveConnections()); + assertEquals(1, jdbc.update("UPDATE comment SET nickname=nickname WHERE id=?", comment.getId())); + var expiredSession = sessions.createSession(); + org.springframework.session.Session session = expiredSession; + session.setMaxInactiveInterval(java.time.Duration.ofSeconds(1)); + session.setLastAccessedTime(now.minusSeconds(120)); + sessions.save(expiredSession); + scheduler.schedule(() -> { sessions.cleanUpExpiredSessions(); publisher.publishDueBatch(); published.countDown(); }, Instant.now()); + assertTrue(failed.await(20, TimeUnit.SECONDS)); + assertTrue(published.await(5, TimeUnit.SECONDS)); + assertTrue(TimeUnit.NANOSECONDS.toSeconds(System.nanoTime()-start) < 22); + publisher.publishDueBatch(); + assertEquals(1, jdbc.queryForObject("SELECT count(*) FROM article_revision WHERE article_id=?", Integer.class, due.getId())); + assertEquals("APPROVED", jdbc.queryForObject("SELECT status FROM comment WHERE id=?", String.class, comment.getId())); + assertEquals(0, jdbc.queryForObject("SELECT count(*) FROM spring_session WHERE session_id=?", Integer.class, session.getId())); + fake.get(5, TimeUnit.SECONDS); + } + } } diff --git a/apps/api/src/test/java/io/haoblog/PublicApiIT.java b/apps/api/src/test/java/io/haoblog/PublicApiIT.java index 27e66c9..773e66d 100644 --- a/apps/api/src/test/java/io/haoblog/PublicApiIT.java +++ b/apps/api/src/test/java/io/haoblog/PublicApiIT.java @@ -23,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.junit.jupiter.api.Assertions.*; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/ToolAdminIT.java b/apps/api/src/test/java/io/haoblog/ToolAdminIT.java index 6523179..1b00525 100644 --- a/apps/api/src/test/java/io/haoblog/ToolAdminIT.java +++ b/apps/api/src/test/java/io/haoblog/ToolAdminIT.java @@ -34,6 +34,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) @Testcontainers @SpringBootTest @AutoConfigureMockMvc diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationMailerTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationMailerTest.java index e9535ad..85a9992 100644 --- a/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationMailerTest.java +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationMailerTest.java @@ -45,7 +45,7 @@ void sendsOnlyRequiredCommentSummaryFields() { .thenReturn(Optional.of(new ArticleCommentLookup.NotificationArticle("文章标题"))); when(site.get()).thenReturn(new SiteService.SiteResult("HaoBlog", "desc", "https://blog.example.invalid", "Hao")); - new CommentNotificationMailer(comments, articles, site, properties, mailSender).send(COMMENT_ID); + new CommentNotificationMailer(comments, articles, site, properties, mailSender, org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class)).send(COMMENT_ID); ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); verify(mailSender).send(captor.capture()); @@ -55,7 +55,7 @@ void sendsOnlyRequiredCommentSummaryFields() { assertFalse(message.getText().contains("email")); assertEquals(true, message.getText().contains("昵称:Hao")); assertEquals(true, message.getText().contains("文章:文章标题")); - assertEquals(true, message.getText().contains("Studio 审核链接:https://blog.example.invalid/studio/comments?commentId=" + COMMENT_ID)); + assertEquals(true, message.getText().contains("Studio 管理链接:https://blog.example.invalid/studio/comments?commentId=" + COMMENT_ID)); } @Test @@ -69,7 +69,7 @@ void hidesMailSenderExceptionDetails() { .when(mailSender).send(any(SimpleMailMessage.class)); CommentNotificationException exception = assertThrows(CommentNotificationException.class, - () -> new CommentNotificationMailer(comments, articles, site, properties, mailSender).send(COMMENT_ID)); + () -> new CommentNotificationMailer(comments, articles, site, properties, mailSender, org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class)).send(COMMENT_ID)); assertEquals("Comment notification could not be sent", exception.getMessage()); } diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationOutboxProcessorTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationOutboxProcessorTest.java index 5a3e1fd..bf23e63 100644 --- a/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationOutboxProcessorTest.java +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationOutboxProcessorTest.java @@ -42,7 +42,7 @@ class CommentNotificationOutboxProcessorTest { void successMarksProcessed() { OutboxEvent event = event(); when(repository.findAvailable(any(), any(), any(), any())).thenReturn(List.of(event)); - when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(repository.findByIdForUpdate(event.getId())).thenReturn(Optional.of(event)); processor(true).processDueBatch(); @@ -56,8 +56,8 @@ void smtpFailureIsRetriedAndDoesNotBlockOtherEvents() { OutboxEvent failed = event(); OutboxEvent successful = new OutboxEvent(UUID.randomUUID(), "COMMENT_CREATED", payload(), NOW, NOW); when(repository.findAvailable(any(), any(), any(), any())).thenReturn(List.of(failed, successful)); - when(repository.findById(failed.getId())).thenReturn(Optional.of(failed)); - when(repository.findById(successful.getId())).thenReturn(Optional.of(successful)); + when(repository.findByIdForUpdate(failed.getId())).thenReturn(Optional.of(failed)); + when(repository.findByIdForUpdate(successful.getId())).thenReturn(Optional.of(successful)); doThrow(new CommentNotificationException()).when(mailer).send(failed.getAggregateId()); processor(true).processDueBatch(); @@ -72,7 +72,7 @@ void smtpFailureIsRetriedAndDoesNotBlockOtherEvents() { void fifthFailureIsTerminal() { OutboxEvent event = event(); when(repository.findAvailable(any(), any(), any(), any())).thenReturn(List.of(event)); - when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(repository.findByIdForUpdate(event.getId())).thenReturn(Optional.of(event)); doThrow(new CommentNotificationException()).when(mailer).send(event.getAggregateId()); for (int attempt = 0; attempt < OutboxEventStateService.MAX_ATTEMPTS; attempt++) { @@ -88,7 +88,7 @@ void fifthFailureIsTerminal() { void disabledNotificationDrainsWithoutSending() { OutboxEvent event = event(); when(repository.findAvailable(any(), any(), any(), any())).thenReturn(List.of(event)); - when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(repository.findByIdForUpdate(event.getId())).thenReturn(Optional.of(event)); processor(false).processDueBatch(); @@ -106,21 +106,21 @@ void processedEventsAreNotClaimedAgain() { } @Test - void claimsOnlyACommentBatchOfTen() { + void claimsOnlyOneCommentAtATime() { when(repository.findAvailable(any(), any(), any(), any())).thenReturn(List.of()); processor(true).processDueBatch(); var captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); verify(repository).findAvailable(org.mockito.ArgumentMatchers.eq("COMMENT_CREATED"), any(), any(), captor.capture()); - assertEquals(10, captor.getValue().getPageSize()); + assertEquals(1, captor.getValue().getPageSize()); } @Test void failureLogsDoNotContainSensitiveExceptionDetails() { OutboxEvent event = event(); when(repository.findAvailable(any(), any(), any(), any())).thenReturn(List.of(event)); - when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(repository.findByIdForUpdate(event.getId())).thenReturn(Optional.of(event)); doThrow(new IllegalStateException("smtp-password owner@example.invalid comment body")) .when(mailer).send(event.getAggregateId()); diff --git a/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java index f8c9939..87526ad 100644 --- a/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/comment/application/CommentServiceTest.java @@ -58,12 +58,12 @@ void setUp() { } @Test - void createsPendingCommentAndOutboxInOneApplicationFlow() { + void publishesCommentAndOutboxInOneApplicationFlow() { var challenge = challenges.issue(ARTICLE_ID); clock.advance(Duration.ofSeconds(3)); var result = service.create("post", command(challenge.token(), "hello"), "A".repeat(43), "192.0.2.1"); - assertEquals(CommentStatus.PENDING, result.status()); + assertEquals(CommentStatus.APPROVED, result.status()); assertNotNull(result.id()); assertNotNull(result.deleteToken()); verify(comments).save(any(Comment.class)); @@ -100,7 +100,7 @@ void honeypotIsAcceptedWithoutPersistenceAndDuplicateIsAConflict() { clock.advance(Duration.ofSeconds(3)); var accepted = service.create("post", new CommentService.CreateCommand( "Hao", null, "normal text", null, honeypotChallenge.token(), "filled", null), null, "192.0.2.1"); - assertEquals(CommentStatus.PENDING, accepted.status()); + assertEquals(CommentStatus.APPROVED, accepted.status()); assertNull(accepted.id()); verifyNoInteractions(comments, outbox); diff --git a/apps/api/src/test/java/io/haoblog/media/application/MediaUploadServiceTest.java b/apps/api/src/test/java/io/haoblog/media/application/MediaUploadServiceTest.java index f840ccd..ea9e92f 100644 --- a/apps/api/src/test/java/io/haoblog/media/application/MediaUploadServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/media/application/MediaUploadServiceTest.java @@ -37,7 +37,7 @@ void setUp() { properties.setMaxSizeBytes(5 * 1024 * 1024L); properties.setMaxDimension(2560); service = new MediaUploadService(uploads, assets, storage, references, - properties, Clock.fixed(now, ZoneOffset.UTC)); + properties, Clock.fixed(now, ZoneOffset.UTC), mock(org.springframework.transaction.PlatformTransactionManager.class)); when(storage.createUploadGrant(any(), any())).thenAnswer(invocation -> { ObjectStorage.UploadSpec spec = invocation.getArgument(0); return new ObjectStorage.UploadGrant("https://mock.invalid/", Map.of("key", spec.objectKey()), now.plusSeconds(300)); diff --git a/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java index 4faf356..d269d1f 100644 --- a/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; class SiteServiceTest { @Test @@ -42,6 +43,10 @@ void validatesManifestByProfileAndRejectsEnablingWithoutConfiguration() { assertEquals("http://localhost:8080/music.json", SiteService.normalizeMusicManifestUrl(" http://localhost:8080/music.json ", "local")); + assertEquals("http://[::1]:8080/music.json", + SiteService.normalizeMusicManifestUrl("http://[::1]:8080/music.json", "local")); + assertThrows(IllegalArgumentException.class, + () -> SiteService.normalizeMusicManifestUrl("http://[::1]:8080/music.json", "prod")); assertEquals("https://cdn.example.test/music.json", SiteService.normalizeMusicManifestUrl("https://cdn.example.test/music.json", "prod")); assertThrows(IllegalArgumentException.class, @@ -69,4 +74,23 @@ void publicMusicFlagSafelyTurnsOffWhenManifestIsMissing() { assertEquals(false, result.musicEnabled()); assertEquals(true, result.threeDEnabled()); } + + @Test + void storesAdminManifestAndPrefersItOverEnvironmentFallback() { + var repository = mock(SiteSettingRepository.class); + var setting = new SiteSetting("default", "HaoBlog", "Night station"); + when(repository.findBySiteKey("default")).thenReturn(Optional.of(setting)); + when(repository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + var service = new SiteService(repository, "https://blog.example.test", "Hao", + "https://fallback.example.test/music.json", "prod"); + + var updated = service.updateSettings(0, true, true, false, + "https://cdn.example.test/music.json"); + + assertEquals("https://cdn.example.test/music.json", updated.musicManifestUrl()); + assertEquals("https://cdn.example.test/music.json", service.get().musicManifestUrl()); + var invalid = assertThrows(io.haoblog.shared.web.ProblemException.class, + () -> service.updateSettings(0, true, true, false, "http://cdn.example.test/music.json")); + assertEquals("MUSIC_MANIFEST_URL_INVALID", invalid.getCode()); + } } diff --git a/apps/web/app/components/articles/CommentSignalSection.vue b/apps/web/app/components/articles/CommentSignalSection.vue index 59c963c..baecaba 100644 --- a/apps/web/app/components/articles/CommentSignalSection.vue +++ b/apps/web/app/components/articles/CommentSignalSection.vue @@ -7,7 +7,9 @@ type CommentPage = components['schemas']['CommentPageResponse'] type CommentView = components['schemas']['CommentView'] type CommentFormContext = components['schemas']['CommentFormContext'] type Submission = components['schemas']['CommentSubmissionResponse'] -type FormStatus = 'idle' | 'loading' | 'submitting' | 'pending' | 'rate-limited' | 'error' +type FormStatus = 'idle' | 'loading' | 'submitting' | 'published' | 'rate-limited' | 'error' + +const emit = defineEmits<{ published: [] }>() const props = defineProps<{ slug: string @@ -148,12 +150,13 @@ async function submitComment() { }, }) rememberToken(result.id, result.deleteToken) - formStatus.value = 'pending' - formMessage.value = '评论已接收,待审核通过后会回到这条回波信号。' + formStatus.value = 'published' + formMessage.value = '评论已发布。' form.content = '' replyTo.value = null formContext.value = null formReadyAt.value = 0 + emit('published') } catch (error) { const status = responseStatus(error) retryAfter.value = status === 429 ? responseRetryAfter(error) : null diff --git a/apps/web/app/components/error/ErrorGameCenter.client.vue b/apps/web/app/components/error/ErrorGameCenter.client.vue new file mode 100644 index 0000000..0a53362 --- /dev/null +++ b/apps/web/app/components/error/ErrorGameCenter.client.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/apps/web/app/components/error/Game2048.client.vue b/apps/web/app/components/error/Game2048.client.vue new file mode 100644 index 0000000..a555194 --- /dev/null +++ b/apps/web/app/components/error/Game2048.client.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/apps/web/app/components/error/MinesweeperGame.client.vue b/apps/web/app/components/error/MinesweeperGame.client.vue new file mode 100644 index 0000000..3b68219 --- /dev/null +++ b/apps/web/app/components/error/MinesweeperGame.client.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/apps/web/app/components/error/SignalRepair.client.vue b/apps/web/app/components/error/SignalRepair.client.vue deleted file mode 100644 index 2ca2628..0000000 --- a/apps/web/app/components/error/SignalRepair.client.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - diff --git a/apps/web/app/components/error/SnakeGame.client.vue b/apps/web/app/components/error/SnakeGame.client.vue new file mode 100644 index 0000000..e5577b7 --- /dev/null +++ b/apps/web/app/components/error/SnakeGame.client.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/apps/web/app/components/music/MusicConsole.client.vue b/apps/web/app/components/music/MusicConsole.client.vue new file mode 100644 index 0000000..754f9ec --- /dev/null +++ b/apps/web/app/components/music/MusicConsole.client.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/apps/web/app/components/music/SignalTape.client.vue b/apps/web/app/components/music/SignalTape.client.vue deleted file mode 100644 index 5dbb8c8..0000000 --- a/apps/web/app/components/music/SignalTape.client.vue +++ /dev/null @@ -1,277 +0,0 @@ - - - - - diff --git a/apps/web/app/error.vue b/apps/web/app/error.vue index d796934..84876ff 100644 --- a/apps/web/app/error.vue +++ b/apps/web/app/error.vue @@ -1,15 +1,12 @@ diff --git a/apps/web/app/layouts/studio.vue b/apps/web/app/layouts/studio.vue index 84cb2e7..ef057b2 100644 --- a/apps/web/app/layouts/studio.vue +++ b/apps/web/app/layouts/studio.vue @@ -10,7 +10,7 @@