Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ HAOBLOG_ADMIN_PASSWORD_HASH=replace-with-a-bcrypt-hash
HAOBLOG_SESSION_COOKIE_SECURE=false
HAOBLOG_PUBLIC_BASE_URL=http://localhost:3000
HAOBLOG_AUTHOR_NAME=Hao
HAOBLOG_COMMENT_SECURITY_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

# Comment notification is disabled locally. Never put real SMTP credentials in this file.
HAOBLOG_COMMENT_NOTIFICATION_ENABLED=false
# HAOBLOG_COMMENT_NOTIFICATION_SMTP_HOST=smtp.example.invalid
# HAOBLOG_COMMENT_NOTIFICATION_SMTP_PORT=587
# HAOBLOG_COMMENT_NOTIFICATION_SMTP_USERNAME=replace-with-smtp-username
# HAOBLOG_COMMENT_NOTIFICATION_SMTP_PASSWORD=replace-with-smtp-password
# HAOBLOG_COMMENT_NOTIFICATION_SMTP_TLS=true
# HAOBLOG_COMMENT_NOTIFICATION_RECIPIENT=owner@example.invalid
# HAOBLOG_COMMENT_NOTIFICATION_FROM=haoblog@example.invalid

# OSS is disabled locally by default. These are server-only values; never put a real key in Git.
HAOBLOG_OSS_ENABLED=false
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
pull_request:

jobs:
stage-3-acceptance:
stage-4-acceptance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -44,6 +44,7 @@ jobs:
- name: PostgreSQL integration tests
env:
HAOBLOG_PUBLIC_BASE_URL: http://localhost:3000
HAOBLOG_COMMENT_SECURITY_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
run: ./mvnw failsafe:integration-test failsafe:verify
working-directory: apps/api

Expand Down Expand Up @@ -98,6 +99,13 @@ jobs:
env:
LHCI_EXISTING_SERVER: true
LHCI_BASE_URL: http://localhost
LHCI_ARTICLE_PATH: /articles/s3-08-advanced-markdown
run: corepack pnpm web:lighthouse
- name: Lighthouse public tools budgets
env:
LHCI_EXISTING_SERVER: true
LHCI_BASE_URL: http://localhost
LHCI_ARTICLE_PATH: /tools
run: corepack pnpm web:lighthouse
- name: Reject whitespace errors
run: git diff --check
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,16 @@ The current repository uses one root pnpm workspace and one Maven application. N
- API tests (POSIX shell): `cd apps/api && ./mvnw test`
- API package (Windows PowerShell): `cd apps/api; .\mvnw.cmd verify`
- API package (POSIX shell): `cd apps/api && ./mvnw verify`
- PostgreSQL/pgvector integration tests (Windows PowerShell): `cd apps/api; .\mvnw.cmd failsafe:integration-test failsafe:verify`
- Web type check: `pnpm --dir apps/web typecheck`
- Web tests: `pnpm --dir apps/web test`
- Web production build: `pnpm --dir apps/web build`
- Full Chromium E2E: `pnpm --dir apps/web e2e`
- Article client bundle budget: `pnpm web:budget`
- Lighthouse with an existing server: set `LHCI_EXISTING_SERVER=true`, `LHCI_BASE_URL=http://localhost` and `LHCI_ARTICLE_PATH` to `/articles/s3-08-advanced-markdown` or `/tools`, then run `pnpm web:lighthouse`; on Windows also use a task-local `TEMP`/`TMP` directory to avoid Chrome cleanup EPERM
- 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`
- 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.
Expand Down
64 changes: 2 additions & 62 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,65 +1,5 @@
# HaoBlog

HaoBlog is a Java 21 and Nuxt 4 modular-monolith developer blog. The public site is SSR-first; `/studio` is client-rendered. 阶段三已形成“极夜观测站”公开阅读体验,包含安全高级 Markdown、TOC、SEO/JSON-LD、RSS、Sitemap、无 JavaScript 与数据节省路径。
HaoBlog 是一个面向开发者的低成本个人博客与数字花园,采用 Java 21、Spring Boot、Nuxt 4、Vue 3 和 PostgreSQL/pgvector 构建。项目使用模块化单体架构,公开站点以服务端渲染为核心,管理后台通过 `/studio` 提供内容运营能力。

## Local prerequisites

- Java 21
- Maven Wrapper (included in `apps/api`)
- Node.js 24 LTS
- pnpm 11.16.0
- Docker Desktop with Compose

Copy `.env.example` to `.env` and replace the local database password before starting PostgreSQL.

完整的环境准备、管理员 BCrypt 配置、Compose 启动、直接运行 API/Web、验证命令和故障排查请参阅:[本地启动指南](docs/本地启动指南.md)。

## 最短命令

Windows 11 PowerShell:

```powershell
Copy-Item .env.example .env
docker compose --env-file .env -f infra/compose/compose.dev.yml up -d
corepack pnpm install --frozen-lockfile
corepack pnpm --filter @haoblog/api-client generate
corepack pnpm --filter @haoblog/api-client check
corepack pnpm compose:verify
corepack pnpm --dir apps/web typecheck
corepack pnpm --dir apps/web test
corepack pnpm --dir apps/web build
corepack pnpm web:budget
corepack pnpm smoke
docker compose --env-file .env -f infra/compose/compose.dev.yml logs --tail=100
docker compose --env-file .env -f infra/compose/compose.dev.yml down -v
cd apps/api; .\mvnw.cmd -DskipITs verify; .\mvnw.cmd failsafe:integration-test failsafe:verify; cd ../..
```

通用 Shell:

```sh
cp .env.example .env
docker compose --env-file .env -f infra/compose/compose.dev.yml up -d
corepack pnpm install --frozen-lockfile
corepack pnpm --filter @haoblog/api-client generate
corepack pnpm --filter @haoblog/api-client check
corepack pnpm compose:verify
corepack pnpm --dir apps/web typecheck && corepack pnpm --dir apps/web test && corepack pnpm --dir apps/web build
corepack pnpm web:budget
corepack pnpm smoke
docker compose --env-file .env -f infra/compose/compose.dev.yml logs --tail=100
docker compose --env-file .env -f infra/compose/compose.dev.yml down -v
cd apps/api && ./mvnw -DskipITs verify && ./mvnw failsafe:integration-test failsafe:verify
```

开发 Compose 提供 PostgreSQL/pgvector、API 和 Web;API/Web 构建 context 均为仓库根目录。生产 Compose 只允许 Caddy 对外开放 80/443,数据库和 Actuator 不暴露宿主机端口。生产镜像、域名和密码必须通过外部 env 文件注入,不能使用仓库中的示例值。

故障排查:

- 端口占用:`Get-NetTCPConnection -LocalPort 3000,5432,8080`;Shell 使用 `ss -ltnp | grep -E ':3000|:5432|:8080'`。
- 容器健康:`docker compose --env-file .env -f infra/compose/compose.dev.yml ps`。
- 查看日志:`docker compose --env-file .env -f infra/compose/compose.dev.yml logs --tail=100 api web postgres`。
- 数据库迁移失败:先查看 API 日志和 PostgreSQL 健康状态,再执行 `docker compose --env-file .env -f infra/compose/compose.dev.yml down -v` 清理本地开发 volume 后重试。
- Web 无法连接 API:确认 API 健康后检查容器内地址 `http://api:8080`,不要在 Compose 内使用 `localhost`。

阶段三验收顺序是 API `-DskipITs verify`、Web typecheck/test/build、OpenAPI 生成一致性、dev/prod Compose 配置、资源边界、PostgreSQL/pgvector Failsafe、生产四容器健康、Smoke、Chromium Playwright、文章客户端预算、Lighthouse 和 `git diff --check`。生产编排的可复制命令与实际完成状态见 [阶段三准入清单](docs/阶段三准入清单.md)。Playwright 使用仓库测试账号 `admin/password`,应配合 `infra/compose/.env.ci.example` 的临时栈运行,不能用于生产。评论、工具箱功能、AI、RAG、Three.js、终端和部署仍不在阶段三范围内。
项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录和结构化数据等能力,并通过安全 Markdown、资源边界、无 JavaScript、Save-Data 与 reduced-motion 路径保持可访问和可持续的阅读体验。
15 changes: 13 additions & 2 deletions apps/api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-session-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
Expand All @@ -49,12 +50,22 @@
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
<configuration><includes><include>**/*Test.java</include><include>**/*Tests.java</include></includes></configuration>
<configuration>
<includes><include>**/*Test.java</include><include>**/*Tests.java</include></includes>
<systemPropertyVariables>
<haoblog.comment.security-key>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</haoblog.comment.security-key>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.3</version>
<configuration><includes><include>**/*IT.java</include></includes></configuration>
<configuration>
<includes><include>**/*IT.java</include></includes>
<systemPropertyVariables>
<haoblog.comment.security-key>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</haoblog.comment.security-key>
</systemPropertyVariables>
</configuration>
<executions><execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution></executions>
</plugin>
</plugins>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.haoblog.comment.application;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Service;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;

@Service
public class CommentChallengeService {
static final Duration MINIMUM_FILL_TIME = Duration.ofSeconds(3);
static final Duration MAXIMUM_AGE = Duration.ofHours(2);
private static final long MAX_CACHE_SIZE = 4096;

private final Clock clock;
private final CommentSecurityService security;
private final Cache<String, Challenge> challenges = Caffeine.newBuilder()
.maximumSize(MAX_CACHE_SIZE)
.expireAfterWrite(MAXIMUM_AGE)
.build();

public CommentChallengeService(Clock clock, CommentSecurityService security) {
this.clock = clock;
this.security = security;
}

public IssuedChallenge issue(UUID articleId) {
Instant issuedAt = clock.instant();
Instant expiresAt = issuedAt.plus(MAXIMUM_AGE);
String token = security.challengeToken(articleId, issuedAt);
challenges.put(token, new Challenge(articleId, issuedAt, expiresAt));
return new IssuedChallenge(token, expiresAt);
}

public boolean consume(UUID articleId, String token) {
return validateAndConsume(articleId, token) == Validation.VALID;
}

public Validation validateAndConsume(UUID articleId, String token) {
if (token == null || token.isBlank()) return Validation.INVALID;
Instant now = clock.instant();
var result = new Validation[] {Validation.INVALID};
challenges.asMap().computeIfPresent(token, (key, challenge) -> {
if (!challenge.articleId().equals(articleId)) return challenge;
if (now.isBefore(challenge.issuedAt().plus(MINIMUM_FILL_TIME))) {
result[0] = Validation.TOO_EARLY;
return challenge;
}
if (!challenge.expiresAt().isAfter(now)) {
result[0] = Validation.EXPIRED;
return null;
}
result[0] = Validation.VALID;
return null;
});
return result[0];
}

long cacheSize() {
challenges.cleanUp();
return challenges.estimatedSize();
}

public record IssuedChallenge(String token, Instant expiresAt) {}

public enum Validation { VALID, INVALID, TOO_EARLY, EXPIRED }

private record Challenge(UUID articleId, Instant issuedAt, Instant expiresAt) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.haoblog.comment.application;

public class CommentNotificationException extends RuntimeException {
public CommentNotificationException() {
super("Comment notification could not be sent");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.haoblog.comment.application;

import io.haoblog.comment.domain.Comment;
import io.haoblog.comment.persistence.CommentRepository;
import io.haoblog.content.application.ArticleCommentLookup;
import io.haoblog.site.application.SiteService;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

@Service
public class CommentNotificationMailer {
private static final int SUMMARY_LENGTH = 240;

private final CommentRepository comments;
private final ArticleCommentLookup articles;
private final SiteService site;
private final CommentNotificationProperties properties;
private final JavaMailSender mailSender;

public CommentNotificationMailer(CommentRepository comments, ArticleCommentLookup articles,
SiteService site, CommentNotificationProperties properties,
JavaMailSender mailSender) {
this.comments = comments;
this.articles = articles;
this.site = site;
this.properties = properties;
this.mailSender = mailSender;
}

@Transactional(readOnly = true)
public void send(UUID commentId) {
Comment comment = comments.findById(commentId).orElseThrow(CommentNotificationException::new);
ArticleCommentLookup.NotificationArticle article = articles
.findCommentNotificationArticle(comment.getArticleId())
.orElseThrow(CommentNotificationException::new);
String recipient = required(properties.getRecipient());
String from = required(properties.getFrom());
String studioUrl = site.get().siteUrl() + "/studio/comments?commentId=" + commentId;

SimpleMailMessage message = new SimpleMailMessage();
message.setTo(recipient);
message.setFrom(from);
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();
}
}

private static String required(String value) {
if (value == null || value.isBlank()) throw new CommentNotificationException();
return value.trim();
}

private static String summarize(String content) {
String normalized = content == null ? "" : content.replaceAll("\\s+", " ").strip();
if (normalized.codePointCount(0, normalized.length()) <= SUMMARY_LENGTH) return normalized;
int end = normalized.offsetByCodePoints(0, SUMMARY_LENGTH);
return normalized.substring(0, end) + "…";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.haoblog.comment.application;

import io.haoblog.shared.outbox.OutboxEvent;
import io.haoblog.shared.outbox.OutboxEventStateService;
import org.slf4j.MDC;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
public class CommentNotificationOutboxProcessor {
private static final Logger LOG = LoggerFactory.getLogger(CommentNotificationOutboxProcessor.class);

private final OutboxEventStateService state;
private final CommentNotificationMailer mailer;
private final CommentNotificationProperties properties;

public CommentNotificationOutboxProcessor(OutboxEventStateService state, CommentNotificationMailer mailer,
CommentNotificationProperties properties) {
this.state = state;
this.mailer = mailer;
this.properties = properties;
}

@Scheduled(
fixedDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_FIXED_DELAY_MS:60000}",
initialDelayString = "${HAOBLOG_COMMENT_NOTIFICATION_INITIAL_DELAY_MS:5000}"
)
public void processDueBatch() {
for (OutboxEvent event : state.claimCommentCreatedBatch()) {
processOne(event);
}
}

private void processOne(OutboxEvent event) {
UUID traceId = UUID.randomUUID();
try (MDC.MDCCloseable ignored = MDC.putCloseable("traceId", traceId.toString())) {
if (properties.isEnabled()) {
mailer.send(event.getAggregateId());
}
state.markProcessed(event.getId());
LOG.info("评论通知已处理 eventId={} commentId={} traceId={}",
event.getId(), event.getAggregateId(), traceId);
} catch (Exception ignored) {
try {
state.markFailedOrRetry(event.getId());
} catch (Exception stateFailure) {
LOG.warn("评论通知状态更新失败 eventId={} commentId={} traceId={}",
event.getId(), event.getAggregateId(), traceId);
}
LOG.warn("评论通知失败 eventId={} commentId={} traceId={}",
event.getId(), event.getAggregateId(), traceId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.haoblog.comment.application;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "haoblog.comment.notification")
public class CommentNotificationProperties {
private boolean enabled;
private String recipient = "";
private String from = "";

public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getRecipient() { return recipient; }
public void setRecipient(String recipient) { this.recipient = recipient; }
public String getFrom() { return from; }
public void setFrom(String from) { this.from = from; }
}
Loading
Loading