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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ 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
# 音乐清单地址;生产必须使用绝对 HTTPS,本地可使用 http://localhost
HAOBLOG_MUSIC_MANIFEST_URL=
HAOBLOG_COMMENT_SECURITY_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

# Comment notification is disabled locally. Never put real SMTP credentials in this file.
Expand All @@ -34,6 +36,7 @@ HAOBLOG_COMMENT_NOTIFICATION_ENABLED=false
HAOBLOG_OSS_ENABLED=false
# HAOBLOG_OSS_BUCKET=haoblog-bucket
# HAOBLOG_OSS_REGION=cn-guangzhou
# 地域服务 Endpoint 仅用于签名;生产数据请求使用下方自定义公开域名。
# HAOBLOG_OSS_ENDPOINT=https://oss-cn-guangzhou.aliyuncs.com
# HAOBLOG_OSS_ACCESS_KEY_ID=replace-with-server-access-key-id
# HAOBLOG_OSS_ACCESS_KEY_SECRET=replace-with-server-access-key-secret
Expand Down
2 changes: 1 addition & 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-4-acceptance:
stage-5-acceptance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Java / Maven
target/
docs/target/
.mvn/wrapper/maven-wrapper.jar

# Node / Nuxt
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Build HaoBlog as a low-cost developer blog and digital garden using Java and Vue
- Current user request defines the task scope.
- `docs/HaoBlog 完整开发计划.md` defines architecture, modules, resource budgets, APIs, data and milestones.
- `docs/HaoBlog-design-language.md` defines public-site UI/UX, tokens, layout, motion and accessibility.
- `docs/target`: local/private development target plans and admission checklists. These files are not public and must not be committed or pushed; do not create the typo directory `docs/taget`.
- For public-site design work, use the repo skill at `.agents/skills/haoblog-design/SKILL.md` when available.
- If sources conflict, report the conflict and propose the smallest safe resolution. Do not silently override a locked decision.

Expand All @@ -19,7 +20,7 @@ Build HaoBlog as a low-cost developer blog and digital garden using Java and Vue
- `packages/api-client`: reserved package for generated OpenAPI TypeScript types/client.
- `infra/compose/compose.dev.yml`: local PostgreSQL + pgvector development Compose configuration.
- `.github/workflows/ci.yml`: API, Web and Compose configuration CI.
- `docs`: architecture and design documents.
- `docs`: architecture, design and general project documents; private stage target plans/checklists stay under ignored `docs/target` and are never part of the public repository.

Do not create additional services unless the user changes the architecture.

Expand Down Expand Up @@ -80,7 +81,7 @@ The current repository uses one root pnpm workspace and one Maven application. N
- 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
- Lighthouse with an existing server: set `LHCI_EXISTING_SERVER=true`, `LHCI_BASE_URL=http://localhost` and run `pnpm web:lighthouse` serially with `LHCI_ARTICLE_PATH` set to `/`, `/garden`, `/tools` or `/articles/s3-08-advanced-markdown`; set `LHCI_NOINDEX=true` only for a noindex/404 audit, and use `LHCI_NUMBER_OF_RUNS`/`LHCI_CHROME_PORT` only for isolated local runner control; on Windows also use a task-local `TEMP`/`TMP` directory and never share a still-running Chrome instance between routes
- 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`
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,27 @@

HaoBlog 是一个面向开发者的低成本个人博客与数字花园,采用 Java 21、Spring Boot、Nuxt 4、Vue 3 和 PostgreSQL/pgvector 构建。项目使用模块化单体架构,公开站点以服务端渲染为核心,管理后台通过 `/studio` 提供内容运营能力。

项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录和结构化数据等能力,并通过安全 Markdown、资源边界、无 JavaScript、Save-Data 与 reduced-motion 路径保持可访问和可持续的阅读体验。
项目以“极夜观测站”为设计主题,关注长文阅读、内容可发现性和低性能主机上的稳定运行。它提供文章、中文搜索、评论、工具箱、RSS、Sitemap、目录、结构化数据、知识星图、安全终端、双主题、可关闭的首页 Three.js、音乐频谱、404 信号修复和显式准备的 PWA 离线工具箱。公开内容保持 SSR;无 JavaScript、Save-Data、reduced-motion、触摸和重型能力失败时均保留可读路径。

## 本地验证

仓库要求 Java 21、Node.js 24.x、Corepack 管理的 pnpm 11.16.0,以及包含 Compose 的 Docker Desktop。常用检查从仓库根目录执行:

```powershell
Push-Location apps/api
.\mvnw.cmd -DskipITs verify
.\mvnw.cmd failsafe:integration-test failsafe:verify
Pop-Location

corepack pnpm --dir apps/web typecheck
corepack pnpm --dir apps/web test
corepack pnpm --dir apps/web build
corepack pnpm --filter @haoblog/api-client generate
corepack pnpm --filter @haoblog/api-client check
corepack pnpm web:budget
corepack pnpm compose:verify
```

阶段五音乐默认关闭。`HAOBLOG_MUSIC_MANIFEST_URL` 在生产环境必须是绝对 HTTPS 地址;音频不经过 Spring Boot。PWA 只在 production build 或显式测试模式注册,进入 `/tools` 后还需点击“准备离线工具”并二次确认;缓存不包含 API、Studio、文章、评论、图谱、音乐或外部资源。

完整开发环境、独立生产 Compose 验收、Lighthouse 串行路由和清理命令见 [本地启动指南](docs/本地启动指南.md)。阶段目标任务和准入清单属于本地私有验收资料,不纳入公开仓库。本地收口不等于远端 CI、真实 OSS/CDN 与 CORS、真实域名/HTTPS 安装、部署备案或阶段六 30 分钟 2GB 压测通过。
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
import java.time.Clock;
import java.text.Normalizer;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
Expand Down Expand Up @@ -61,6 +64,20 @@ public PublishedBatch listPublishedBatch(int page, int size) {
return new PublishedBatch(result.getContent().stream().map(this::toPublicFeedArticle).toList(), result.hasNext());
}

@org.springframework.transaction.annotation.Transactional(readOnly = true)
public List<GardenArticle> listPublicGardenArticles() {
Map<UUID, GardenArticleBuilder> grouped = new LinkedHashMap<>();
for (var row : repository.findPublicGardenRows(java.time.Instant.now(clock))) {
var article = grouped.computeIfAbsent(row.getArticleId(), ignored -> new GardenArticleBuilder(
row.getArticleId(), row.getSlug(), row.getTitle(), row.getExcerpt(), row.getPublishedAt(),
taxonomy(row.getCategoryName(), row.getCategorySlug())));
if (row.getTagSlug() != null && !row.getTagSlug().isBlank()) {
article.tags.add(taxonomy(row.getTagName(), row.getTagSlug()));
}
}
return grouped.values().stream().map(GardenArticleBuilder::build).toList();
}

public SearchPage search(String query, int page, int size) {
String normalizedQuery = normalizeSearchQuery(query);
if (page < 0 || size < 1 || size > 20) throw new IllegalArgumentException("page/size out of range");
Expand Down Expand Up @@ -111,7 +128,39 @@ public UUID articleId() {
public record PageResult(Page<PublicArticle> page) {}
public record PublishedBatch(List<PublicFeedArticle> items, boolean hasNext) {}
public record PublicFeedArticle(UUID id, String slug, String title, String excerpt, java.time.Instant publishedAt) {}
public record GardenTaxonomy(String name, String slug) {}
public record GardenArticle(UUID id, String slug, String title, String excerpt, java.time.Instant publishedAt,
GardenTaxonomy category, List<GardenTaxonomy> tags) {}
public record SearchPage(String query, Page<PublicSearchArticle> page) {}
public record PublicSearchArticle(UUID id, String slug, String title, String excerpt,
java.time.Instant publishedAt, UUID coverMediaId, boolean commentsEnabled) {}

private static GardenTaxonomy taxonomy(String name, String slug) {
if (slug == null || slug.isBlank()) return null;
return new GardenTaxonomy(name == null || name.isBlank() ? slug : name.trim(), slug.trim().toLowerCase(Locale.ROOT));
}

private static final class GardenArticleBuilder {
private final UUID id;
private final String slug;
private final String title;
private final String excerpt;
private final java.time.Instant publishedAt;
private final GardenTaxonomy category;
private final java.util.Set<GardenTaxonomy> tags = new LinkedHashSet<>();

private GardenArticleBuilder(UUID id, String slug, String title, String excerpt,
java.time.Instant publishedAt, GardenTaxonomy category) {
this.id = id;
this.slug = slug;
this.title = title;
this.excerpt = excerpt;
this.publishedAt = publishedAt;
this.category = category;
}

private GardenArticle build() {
return new GardenArticle(id, slug, title, excerpt, publishedAt, category, List.copyOf(tags));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import java.util.List;

public interface ArticleRevisionRepository extends JpaRepository<ArticleRevision, UUID> {
boolean existsByArticleId(UUID articleId);
Expand Down Expand Up @@ -100,6 +101,25 @@ where a.status in ('PUBLISHED', 'SCHEDULED')
Page<SearchProjection> searchVisible(@Param("pattern") String pattern, @Param("now") Instant now,
Pageable pageable);

@Query(value = """
select r.article_id as "articleId", r.slug as slug, r.title as title, r.excerpt as excerpt,
a.published_at as "publishedAt",
r.category_snapshot ->> 'name' as "categoryName",
r.category_snapshot ->> 'slug' as "categorySlug",
tag.value ->> 'name' as "tagName",
tag.value ->> 'slug' as "tagSlug"
from article_revision r
join article a on a.published_revision_id = r.id
left join lateral jsonb_array_elements(coalesce(r.tag_snapshot, '[]'::jsonb)) as tag(value) on true
where a.status in ('PUBLISHED', 'SCHEDULED')
and a.published_revision_id is not null
and a.published_at is not null
and a.published_at <= :now
order by a.published_at desc, a.id desc,
tag.value ->> 'slug' asc nulls last
""", nativeQuery = true)
List<PublicGardenRow> findPublicGardenRows(@Param("now") Instant now);

@Query("""
select r as revision, a.publishedAt as publishedAt, a.commentsEnabled as commentsEnabled
from ArticleRevision r, Article a
Expand Down Expand Up @@ -158,4 +178,16 @@ interface SearchProjection {
UUID getCoverMediaId();
boolean getCommentsEnabled();
}

interface PublicGardenRow {
UUID getArticleId();
String getSlug();
String getTitle();
String getExcerpt();
Instant getPublishedAt();
String getCategoryName();
String getCategorySlug();
String getTagName();
String getTagSlug();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ public AliyunOssObjectStorage(AliyunOssProperties properties, HttpClient httpCli
throw new IllegalArgumentException("OSS upload limits must be positive");
}
host = properties.getBucket() + "." + endpointHost(properties.getEndpoint());
publicBaseUrl = trimTrailingSlash(properties.getPublicBaseUrl().isBlank()
? "https://" + host : properties.getPublicBaseUrl());
String configuredPublicBaseUrl = properties.getPublicBaseUrl();
publicBaseUrl = normalizePublicBaseUrl(configuredPublicBaseUrl == null || configuredPublicBaseUrl.isBlank()
? "https://" + host : configuredPublicBaseUrl);
}

@Override
Expand Down Expand Up @@ -77,14 +78,14 @@ public UploadGrant createUploadGrant(UploadSpec spec, Instant now) {
fields.put("x-oss-credential", credential);
fields.put("x-oss-date", dateTime);
fields.put("x-oss-signature", signature);
return new UploadGrant("https://" + host + "/", fields, expiresAt);
return new UploadGrant(publicBaseUrl + "/", fields, expiresAt);
}

@Override
public StoredObject head(String objectKey) {
if (!headSlots.tryAcquire()) throw new ObjectStorageException("OSS metadata capacity is temporarily exhausted");
try {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://" + host + "/" + encodePath(objectKey)))
HttpRequest request = HttpRequest.newBuilder(URI.create(publicBaseUrl + "/" + encodePath(objectKey)))
.timeout(Duration.ofSeconds(5))
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.build();
Expand Down Expand Up @@ -166,6 +167,23 @@ private static String endpointHost(String endpoint) {
return uri.getHost();
}

private static String normalizePublicBaseUrl(String value) {
URI uri;
try {
uri = URI.create(value.trim());
} catch (IllegalArgumentException exception) {
throw new IllegalStateException("OSS publicBaseUrl must be an HTTPS origin", exception);
}
String path = uri.getRawPath();
if (!uri.isAbsolute() || !"https".equalsIgnoreCase(uri.getScheme())
|| uri.getHost() == null || uri.getHost().isBlank() || uri.getUserInfo() != null
|| uri.getQuery() != null || uri.getFragment() != null
|| (path != null && !path.isEmpty() && !"/".equals(path))) {
throw new IllegalStateException("OSS publicBaseUrl must be an HTTPS origin");
}
return trimTrailingSlash(value.trim());
}

private static String trimTrailingSlash(String value) { return value.replaceAll("/+$", ""); }

private static String encodePath(String value) {
Expand Down
Loading
Loading