From 0c292f158ce89c24017341362f54d6e50de8e4f4 Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Fri, 4 Sep 2026 16:34:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E5=8F=91=E5=B8=83=E3=80=81404=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E4=B8=AD=E5=BF=83=E4=B8=8E=E9=9F=B3=E4=B9=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- .../CommentNotificationMailer.java | 6 +- .../comment/application/CommentService.java | 4 +- .../io/haoblog/comment/domain/Comment.java | 2 +- .../comment/web/PublicCommentController.java | 2 +- .../haoblog/site/application/SiteService.java | 71 ++-- .../io/haoblog/site/domain/SiteSetting.java | 3 + .../haoblog/site/web/AdminSiteController.java | 10 +- .../site/web/PublicSiteController.java | 9 +- ...7__instant_comments_and_music_manifest.sql | 3 + .../test/java/io/haoblog/ContentModelIT.java | 6 +- .../CommentNotificationMailerTest.java | 2 +- .../application/CommentServiceTest.java | 6 +- .../site/application/SiteServiceTest.java | 20 + .../articles/CommentSignalSection.vue | 9 +- .../error/ErrorGameCenter.client.vue | 49 +++ .../app/components/error/Game2048.client.vue | 64 ++++ .../error/MinesweeperGame.client.vue | 117 ++++++ .../components/error/SignalRepair.client.vue | 77 ---- .../app/components/error/SnakeGame.client.vue | 120 ++++++ .../components/music/MusicConsole.client.vue | 352 ++++++++++++++++++ .../components/music/SignalTape.client.vue | 277 -------------- apps/web/app/error.vue | 43 +-- apps/web/app/layouts/default.vue | 14 +- apps/web/app/layouts/studio.vue | 2 +- apps/web/app/pages/articles/[slug].vue | 3 +- apps/web/app/pages/studio/comments.vue | 8 +- apps/web/app/pages/studio/settings.vue | 35 +- apps/web/app/utils/errorGames.ts | 52 +++ apps/web/app/utils/publicSite.ts | 1 + apps/web/e2e/stage4.spec.ts | 48 +-- apps/web/e2e/stage5.spec.ts | 52 +-- .../scripts/check-article-client-bundle.mjs | 2 +- .../server/routes/music-manifest.json.get.ts | 46 --- apps/web/test/comment-signal.test.ts | 4 +- apps/web/test/error-games.test.ts | 15 + .../test/music-and-game-regressions.test.ts | 57 +++ .../web/test/phase5-static-boundaries.test.ts | 17 +- ...00\345\217\221\350\256\241\345\210\222.md" | 18 +- docs/HaoBlog-design-language.md | 10 +- docs/openapi/public-api.yaml | 17 +- packages/api-client/src/generated.ts | 14 +- 42 files changed, 1101 insertions(+), 570 deletions(-) create mode 100644 apps/api/src/main/resources/db/migration/V17__instant_comments_and_music_manifest.sql create mode 100644 apps/web/app/components/error/ErrorGameCenter.client.vue create mode 100644 apps/web/app/components/error/Game2048.client.vue create mode 100644 apps/web/app/components/error/MinesweeperGame.client.vue delete mode 100644 apps/web/app/components/error/SignalRepair.client.vue create mode 100644 apps/web/app/components/error/SnakeGame.client.vue create mode 100644 apps/web/app/components/music/MusicConsole.client.vue delete mode 100644 apps/web/app/components/music/SignalTape.client.vue create mode 100644 apps/web/app/utils/errorGames.ts delete mode 100644 apps/web/server/routes/music-manifest.json.get.ts create mode 100644 apps/web/test/error-games.test.ts create mode 100644 apps/web/test/music-and-game-regressions.test.ts diff --git a/README.md b/README.md index aea68ff..b428473 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,6 @@ 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 压测通过。 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..0fdb894 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 @@ -44,13 +44,13 @@ 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"); + + "Studio 管理链接:" + studioUrl + "\n"); try { mailSender.send(message); } catch (Exception ignored) { 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/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/site/application/SiteService.java b/apps/api/src/main/java/io/haoblog/site/application/SiteService.java index 1251662..e645c6c 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() { @@ -130,23 +149,29 @@ static String normalizeMusicManifestUrl(String raw, String activeProfiles) { private static final java.util.Set SetOfLocalHosts = java.util.Set.of("localhost", "127.0.0.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/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/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index c9c14d2..82a923c 100644 --- a/apps/api/src/test/java/io/haoblog/ContentModelIT.java +++ b/apps/api/src/test/java/io/haoblog/ContentModelIT.java @@ -57,7 +57,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 +243,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 +274,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/comment/application/CommentNotificationMailerTest.java b/apps/api/src/test/java/io/haoblog/comment/application/CommentNotificationMailerTest.java index e9535ad..499ad5c 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 @@ -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 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/site/application/SiteServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/SiteServiceTest.java index 4faf356..dd29d47 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 @@ -69,4 +70,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..af052cd --- /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 @@