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
24 changes: 2 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project> --env-file <test-env> -f infra/compose/compose.prod.yml -f <override> config --format json` to `node scripts/verify-compose-resources.mjs - <project>`; 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.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、触摸和重型能力失败时均保留可读路径。

## 本地验证

Expand All @@ -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 次尝试,采用至少一次语义。运行态资源验证与权限例外见启动指南。
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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())
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public ResponseEntity<SubmissionResponse> 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()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
import java.util.UUID;

public interface OutboxEventRepository extends JpaRepository<OutboxEvent, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select e from OutboxEvent e where e.id = :id")
java.util.Optional<OutboxEvent> findByIdForUpdate(@Param("id") UUID id);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select e from OutboxEvent e
Expand Down
Loading
Loading