From 1771fb48a9c352bae321c309bbe320dba76434de Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Sun, 23 Aug 2026 20:16:15 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=95=B4=E7=90=86=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=BA=94=E5=87=86=E5=85=A5=E6=96=87=E6=A1=A3=E5=B9=B6=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=E9=98=B6=E6=AE=B5=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\346\240\207\344\273\273\345\212\241.md" | 444 ++++++++++++++++++ ...06\345\205\245\346\270\205\345\215\225.md" | 140 ++++++ 2 files changed, 584 insertions(+) create mode 100644 "docs/taget/\347\254\254\344\272\224\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" create mode 100644 "docs/taget/\351\230\266\346\256\265\344\272\224\345\207\206\345\205\245\346\270\205\345\215\225.md" diff --git "a/docs/taget/\347\254\254\344\272\224\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" "b/docs/taget/\347\254\254\344\272\224\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" new file mode 100644 index 0000000..3cc4fdd --- /dev/null +++ "b/docs/taget/\347\254\254\344\272\224\351\230\266\346\256\265\347\233\256\346\240\207\344\273\273\345\212\241.md" @@ -0,0 +1,444 @@ +# 第五阶段目标任务:非 AI 体验与本地收口 + +## 0. 阶段状态与适用范围 + +本文件是阶段五的目标任务和实现前契约,不代表本阶段业务已经实现。2026-08-23 的“阶段 5 迭代 0”只完成阶段准入、基线记录和文档落盘;S5-01~S5-09 均待后续迭代实施。 + +阶段五定位为“非 AI 体验与本地收口”:在阶段四已有的公开阅读、评论、工具和搜索基础上,补齐知识星图、终端导航、首页标志性动效、音乐与频谱、404 彩蛋、PWA 离线工具箱,以及重型效果的生命周期和降级边界。 + +本阶段继续遵循“仪器而非装饰、降级是一等路径、可读性永远赢”。首页首幕和文章正文必须不依赖 JavaScript;Three.js、图谱渲染、音频分析和 Service Worker 都不能成为内容可见性的前置条件。 + +## 一、阶段目标与非目标 + +### 1.1 阶段目标 + +- 让文章、标签、分类和工具可以通过受限的知识星图被发现,并在移动端退化为可读的时间线。 +- 提供浏览器内白名单终端,支持导航、搜索和主题切换;任何命令都不连接服务器 Shell。 +- 将首页明确实现为“夜空校准 → 星图接入 → 近期观测日志”三幕,首幕为 SSR 静态内容,第二幕才按需加载 Three.js。 +- 提供默认不自动播放的全局音乐、频谱和波形体验;音频只从服务端配置的受信任 OSS/CDN 清单加载。 +- 提供可跳过、可关闭、移动端可降级的“丢失信号修复”404 小游戏。 +- 让 /tools 的浏览器端工具具备最小范围离线能力,不缓存私有数据、文章正文、评论、Studio 或 API 响应。 +- 在 360/768/1280/1600px、键盘、触摸、prefers-reduced-motion、Save-Data、无 JavaScript和资源加载失败路径下,保持可用和可读。 +- 为阶段六的 2GB 加固保留可测量的资源、缓存、并发和生命周期边界。 + +### 1.2 明确非目标 + +本阶段不实现、不验收以下项目: + +- AI Chat、SSE、RAG、Embedding、选区问答、后台创作、模型额度、费用台账和 AI 评测。 +- 域名、HTTPS、ICP/公安备案、部署、ACR、远端 CI、真实生产环境或云资源变更。 +- 真实 OSS 媒体上传改造、真实 SMTP 投递、备份恢复演练和生产合规材料。 +- 30 分钟压力测试、生产容器内存加固、日志轮转和回滚演练;这些属于阶段六或阶段七的外部/运行验收。 +- Studio 曲目 CRUD、音乐上传、曲目审核或数据库音乐表;音乐只消费服务端配置的受信任 OSS/CDN JSON 清单。 +- 通过终端执行任意 Shell、SQL、文件系统、网络抓取或未列入白名单的命令。 +- 把全文、评论正文、管理数据或任意 HTML/JavaScript放入图谱、PWA 缓存或音乐清单。 +- 全屏粒子、大面积模糊玻璃、传统顶部导航、三列文章卡片、九宫格工具卡片或为动效引入 React Framer Motion。 +- 新建微服务、Redis、消息队列、独立搜索服务、MinIO 或新的数据库表;若实现阶段发现确需改变边界,必须先重新进行阶段准入。 + +## 二、既有基线与架构边界 + +- API 仍是 Java 21 + Spring Boot 模块化单体,业务边界为 identity、content、comment、toolbox、ai、media、site、shared。 +- Web 仍是 Nuxt 4 + Vue 3 + TypeScript;公开站点 SSR,/studio CSR。公共内容契约继续由 OpenAPI 和 packages/api-client 负责。 +- 图谱数据由服务端从已公开的文章、标签、分类和工具读模型生成,不向浏览器发送全文,不由客户端拼接管理数据。 +- 音频二进制不经过 Spring Boot;Nuxt 服务端读取受信任 manifest 的地址,浏览器只接收校验后的清单和允许的 HTTPS 音频地址。 +- 重型客户端模块必须路由或交互懒加载,并在路由离开、组件卸载和 Feature Flag 关闭时释放监听器、计时器、Worker、AudioContext、AnalyserNode、Three.js 场景和纹理。 +- 阶段五不修改现有迁移文件,不创建新迁移;S5-01 的第一版 Feature Flag 采用服务端配置/公开读模型,不做 Studio 配置 CRUD。 + +## 三、任务依赖关系 + +~~~mermaid +flowchart LR + S500["S5-00 阶段准入"] --> S501["S5-01 图谱契约与 Feature Flag"] + S501 --> S502["S5-02 知识星图"] + S501 --> S503["S5-03 安全终端与主题"] + S501 --> S504["S5-04 首页三幕与 Three.js"] + S501 --> S505["S5-05 音乐与频谱"] + S501 --> S506["S5-06 404 信号修复"] + S501 --> S507["S5-07 PWA 离线工具箱"] + S502 --> S508["S5-08 性能、生命周期与降级收口"] + S503 --> S508 + S504 --> S508 + S505 --> S508 + S506 --> S508 + S507 --> S508 + S508 --> S509["S5-09 综合验收与文档"] +~~~ + +依赖原则:S5-01 先冻结公开数据和开关,避免视觉组件各自定义数据;S5-02~S5-07 可以并行开发,但都必须把资源释放和关闭开关交给 S5-08 统一验收;S5-09 只在降级、生命周期和公开契约全部可复现后收口。 + +## 四、公共契约、数据结构与 Feature Flag + +以下是后续实现时必须通过 OpenAPI、Nuxt Server Route 或静态资源契约固化的公开接口。本轮不新增接口代码,也不修改 docs/openapi/public-api.yaml。 + +### 4.1 公开接口 + +| 接口 | 所属 | 目的与硬边界 | +| --- | --- | --- | +| GET /api/v1/public/site | Spring Boot | 在现有站点响应中增加公开的 featureFlags;保留现有 commentsEnabled、ETag 和缓存语义。不返回密钥、内部配置或 Studio 权限。 | +| GET /api/v1/public/knowledge-graph | Spring Boot | 返回受限图谱 JSON;只含公开节点摘要、关系、权重、版本和 ETag,不含 Markdown、评论、未发布内容或管理字段。 | +| GET /music-manifest.json | Nuxt Server Route/静态边界 | 由服务端配置的 HTTPS OSS/CDN manifest 地址提供校验后的清单;不提供 Studio 曲目 CRUD,不代理音频二进制。若源不可用,返回空清单并保留播放器降级文案。 | +| GET /manifest.webmanifest | Web 静态资源 | 只描述公开站点和工具箱的安装元数据,不声明 Studio、评论写入或后台能力。 | +| GET /sw.js | Web 静态资源 | 只负责公开工具箱的最小缓存和更新;不得拦截或持久化 /api/**、/studio/**、评论提交、登录和文章私有预览。 | + +终端、404 游戏、频谱和 Three.js 都是浏览器内功能,不另开 API。终端复用现有公开文章、搜索、工具和站点接口;ask 命令在阶段五只返回“AI 尚未在本阶段开放”的本地提示,不发送 AI 请求。 + +### 4.2 图谱数据结构 + +~~~ts +type KnowledgeGraphNodeKind = 'ARTICLE' | 'TAG' | 'CATEGORY' | 'TOOL' +type KnowledgeGraphRelation = 'TAGGED' | 'CATEGORIZED' | 'RELATED_TOOL' | 'SHARED_TAG' + +interface KnowledgeGraphNode { + id: string + kind: KnowledgeGraphNodeKind + label: string + href: string | null + weight: number + publishedAt?: string +} + +interface KnowledgeGraphEdge { + source: string + target: string + relation: KnowledgeGraphRelation + weight: number +} + +interface KnowledgeGraphResponse { + schemaVersion: 1 + generatedAt: string + nodes: KnowledgeGraphNode[] + edges: KnowledgeGraphEdge[] +} +~~~ + +服务端默认上限为 120 个节点、360 条边;实际值以实现时的资源观测为准,但必须有硬上限。href 只能是站内安全路径,节点标签和类型必须同时表达状态,不能只用颜色区分。移动端无需请求全文,退化视图复用节点的标签、类型和站内链接。 + +### 4.3 音乐清单数据结构 + +~~~ts +interface MusicManifest { + schemaVersion: 1 + defaultTrackId: string | null + tracks: Array<{ + id: string + title: string + artist: string | null + audioUrl: string + coverUrl: string | null + license: 'ORIGINAL' | 'CC_BY' | 'CC_BY_SA' | 'OTHER_AUTHORIZED' + attributionUrl: string | null + }> +} +~~~ + +服务端只信任配置的 HTTPS OSS/CDN 来源和允许的 host;客户端仍需校验 schema、URL 协议、条目数量和必填字段。建议最多 30 首;音频地址只在用户点击播放后才加载,默认不自动播放。每首曲目必须有授权说明或归属链接。 + +### 4.4 Feature Flag + +第一版采用服务端配置并通过 GET /api/v1/public/site 下发;不增加 Studio 开关编辑页面,不把开关写入浏览器可篡改的“真值”。客户端检测到缺失、非法或接口失败时,一律选择关闭重型效果的安全默认值。 + +| Flag | 作用 | 安全默认 | 依赖 | +| --- | --- | --- | --- | +| terminal | 开放 ⌘K/Ctrl+K 白名单终端 | false | 现有公开文章、搜索、工具接口 | +| knowledgeGraph | 图谱 Canvas/Force 视图 | false | S5-01 图谱契约 | +| homeScene | 首页第二幕 Three.js | false | knowledgeGraph 或静态首页数据 | +| music | 全局播放器和清单 | false | 服务端 manifest | +| spectrum | Web Audio 频谱/波形 | false | music、用户播放手势 | +| signalRepair404 | 404 三步修复小游戏 | false | 本地组件 | +| pwa | 工具箱 manifest、Service Worker 和离线缓存 | false | 工具页面 | + +data-theme="night|blueprint" 是用户本地选择,不是远端 Feature Flag;必须使用 html data-theme 和 CSS Token。prefers-reduced-motion、Save-Data、触摸和设备能力属于运行时降级信号,不得被远端开关覆盖。AI 相关开关不在本阶段新增。 + +## 五、详细任务清单 + +### S5-00:阶段准入 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P0,阶段准入 | +| 前置依赖 | 无 | +| 工作量 | 2–4 小时 | +| 公开接口 | 无 | +| 数据结构/Flag | 冻结本文件和准入清单,不创建运行时结构 | + +目标:记录阶段四本地验收基线、当前分支和用户改动,确认阶段五只做非 AI 体验与本地收口。 + +非目标:不恢复被用户删除的阶段四文档,不提交、不推送、不部署,不写业务代码、OpenAPI、迁移或空类。 + +验收标准: + +- 阶段 2/3 历史文档、阶段 4 收口文档的来源和路径差异可追溯。 +- 当前环境命令、API/Web/OpenAPI/Compose 入口和 Git 状态已记录。 +- 阶段五任务、依赖、外部未执行项目和完成定义已落盘。 +- 用户对 AGENTS.md 和完整开发计划的未提交修改保持原样。 + +测试场景:检查 git status --short --untracked-files=all、git diff --check、文档链接和目标路径;不运行会改变业务状态的命令。 + +### S5-01:图谱契约与 Feature Flag + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P0 | +| 前置依赖 | S5-00 | +| 工作量 | 6–10 小时 | +| 公开接口 | GET /api/v1/public/site、GET /api/v1/public/knowledge-graph | +| 数据结构/Flag | KnowledgeGraphResponse、4 类节点、7 个阶段五开关 | + +目标:冻结图谱的最小公开读模型、ETag/缓存、节点上限、站内链接策略和体验开关,让后续组件不自行读取 JPA 实体或管理 API。 + +非目标:不返回全文、不做全文搜索替代、不增加数据库表、不做 Studio 配置 CRUD、不开放 AI 或通用远程配置。 + +验收标准: + +- OpenAPI 中的响应字段与生成客户端一致,DTO 不暴露实体。 +- 只返回已公开文章、已公开工具及其标签/分类关系;草稿、预览、归档和后台字段永不出现。 +- ETag 对图谱版本和影响响应的数据稳定,接口失败时客户端可使用空/静态回退。 +- 开关关闭或响应缺失时,页面仍能显示静态导航和文本内容。 +- 节点和边数量、字符串长度、响应体大小有硬上限。 + +测试场景:空站点、单节点、重复关系、未发布文章、越界节点、ETag/304、未知 flag、损坏 JSON、服务端 5xx 和客户端默认关闭。 + +### S5-02:知识星图 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P1 | +| 前置依赖 | S5-01 | +| 工作量 | 12–18 小时 | +| 公开接口 | GET /api/v1/public/knowledge-graph | +| 数据结构/Flag | 图谱节点/边、knowledgeGraph | + +目标:使用 Canvas + 受限 Force 布局呈现文章、标签、分类和工具的关系;单击显示摘要导航,双击进入安全站内路径。 + +非目标:不在浏览器拉取全文,不引入全量 D3,不做无限缩放、用户编辑关系或实时协作,不依赖 Three.js 才能使用图谱。 + +实现边界:只评估和引入 d3-force/d3-scale 等最小模块,并按路由懒加载;若资源预算不通过,保留同一契约并退回静态 SVG/时间线,不扩大依赖。 + +验收标准: + +- 桌面可键盘聚焦、选择和打开节点;节点类型、标签和关系不只通过颜色表达。 +- 移动端、低性能设备、Save-Data、reduced-motion 或图谱失败时退化为可读时间线。 +- 图谱不进入文章详情初始客户端闭包;离开页面会取消布局、移除监听器并释放 Canvas。 +- 节点数量达到上限时仍可稳定渲染,超限时按公开权重确定性截断。 + +测试场景:空图、最大图、断链、重复节点、键盘导航、触摸点击、双击进入、360px、禁用 JS、Save-Data、reduced-motion、动态块加载失败和路由离开。 + +### S5-03:安全终端与主题 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P1 | +| 前置依赖 | S5-01 | +| 工作量 | 8–12 小时 | +| 公开接口 | 复用文章列表、搜索、工具和站点公开接口;不新增 Shell API | +| 数据结构/Flag | 白名单命令 AST、终端历史上限、terminal、data-theme | + +目标:通过 Dock 或 Ctrl/Cmd+K 打开可访问的命令终端,支持 help、ls posts、ls tools、grep "词"、open /articles/...、theme blueprint 和 theme night。 + +非目标:不执行 Shell/SQL/JS,不读取本地文件,不拼接任意 URL,不实现 AI ask 请求;ask 只能返回阶段未开放提示或转到本地搜索。 + +验收标准: + +- Parser 只接受固定命令和长度上限,未知命令返回文本错误,不抛出页面级异常。 +- 所有路径、搜索词、终端历史和输出均有长度/条数上限;不把用户输入写入 HTML 为可执行内容。 +- Dialog 具备 role="dialog"、aria-modal、焦点陷阱、Esc 关闭和键盘历史;移动端不依赖 hover。 +- theme blueprint 和 Logo 五连击只改变 html data-theme 与 CSS Token;刷新后可恢复,无法写入服务端权限。 +- 不支持 backdrop-filter 时使用不透明背景;reduced-motion 时直接切换状态。 + +测试场景:命令注入字符、超长输入、未授权路径、键盘/触摸、焦点恢复、刷新主题、localStorage 不可用、旧浏览器、ask 降级和公开 API 失败。 + +### S5-04:首页三幕与 Three.js + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P1 | +| 前置依赖 | S5-01;图谱契约可用后接入 S5-02 | +| 工作量 | 12–18 小时 | +| 公开接口 | 复用站点、文章和图谱公开读模型 | +| 数据结构/Flag | 三幕状态、静态 SVG 回退、homeScene | + +目标:实现“夜空校准”SSR 首幕、“星图接入”滚动触发幕、“近期观测日志”SSR 第三幕。Three.js 只在第二幕进入视口后动态导入。 + +非目标:不在首屏加载 Three.js,不让 3D 负责标题/导航/近期文章,不把 3D 放入文章或 Studio 包,不为失败状态显示空白画布。 + +验收标准: + +- 禁用 JavaScript 时首幕标题、说明、导航和近期日志仍在 HTML 中;第三幕仍可阅读。 +- homeScene=false、移动端、Save-Data、reduced-motion、WebGL 不可用或动态块失败时显示静态 SVG/文本关系图。 +- Three.js 与可能的编排模块只进入首页按需 chunk,压缩资源目标小于 500KB;文章初始包不包含它们。 +- 首页离开时销毁 geometry/material/texture/renderer、取消 RAF 和 observers,重复进入不累积资源。 +- 动效只使用 transform/opacity 等合成属性,扫描线和呼吸灯优先 CSS-first。 + +测试场景:SSR/无 JS、首屏不发 3D 请求、滚动触发、快速进出视口、WebGL 失败、动态 import 失败、重复路由进入、360px、Save-Data、reduced-motion、键盘和屏幕阅读器。 + +### S5-05:音乐与频谱 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P1 | +| 前置依赖 | S5-01 | +| 工作量 | 8–12 小时 | +| 公开接口 | Nuxt GET /music-manifest.json;音频由 manifest 指向 OSS/CDN | +| 数据结构/Flag | MusicManifest、播放器状态、music、spectrum | + +目标:提供跨路由持续的轻量播放器和可关闭的 Web Audio 频谱/波形;播放只由用户手势开始,音频不经 Spring Boot。 + +非目标:不自动播放、不做上传和曲目 CRUD、不保存用户音频、不在服务端转码、不把 AudioContext 放进文章首屏,不把外部任意 URL 当作可信音频。 + +验收标准: + +- 服务端只读取配置的 HTTPS manifest 来源,校验 schema、host、协议、授权字段和条目上限;源失败时播放器可隐藏或显示离线提示。 +- 用户未点击播放前不请求音频、不创建持续 AudioContext;浏览器拒绝 autoplay 时页面仍正常。 +- 路由切换不打断播放;离开播放器所有者或关闭频谱时释放 analyser、MediaElementSource、RAF 和事件监听器,不泄漏到 Studio。 +- 频谱失败、Save-Data、reduced-motion、低性能或不支持 Web Audio 时只保留播放控制和静态状态灯。 +- UI 使用 role="region"/适当按钮名称和 aria-live 的低频状态播报;颜色不作为唯一状态。 + +测试场景:合法/损坏/超大/跨域 manifest、非 HTTPS URL、未授权条目、用户手势、暂停/继续、路由切换、音频错误、AudioContext 拒绝、无 Web Audio、Save-Data、reduced-motion 和 Studio 隔离。 + +### S5-06:404 信号修复 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P1 | +| 前置依赖 | S5-01 | +| 工作量 | 4–6 小时 | +| 公开接口 | 复用站内搜索;无专用后端接口 | +| 数据结构/Flag | 本地 RepairState、signalRepair404 | + +目标:在现有 error.vue 中提供轻量三步断点修复游戏,成功后回到首页;失败、关闭或无 JavaScript 时始终保留搜索和文章建议。 + +非目标:不使用 Three.js、Canvas 大场景、网络排行榜、登录、积分、远端存档或阻塞错误页离开。 + +验收标准: + +- 错误页的状态码、标题、原因和可访问的返回路径不依赖游戏脚本。 +- 游戏状态只在内存中保存;移动端、reduced-motion、Save-Data 或 flag 关闭时显示搜索框和建议文章。 +- 任意步骤失败都能重置或跳过;成功后只进行普通站内导航。 +- 控件可键盘操作,断点不只用颜色表达,动效可直接关闭。 + +测试场景:404/500、禁用 JS、flag 关闭、移动端、键盘、重复点击、刷新、游戏失败/成功、搜索接口失败和返回首页。 + +### S5-07:PWA 离线工具箱 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P1 | +| 前置依赖 | S5-01;现有工具箱完成 | +| 工作量 | 8–14 小时 | +| 公开接口 | /manifest.webmanifest、/sw.js;不缓存后台 API | +| 数据结构/Flag | 静态 manifest、缓存版本/白名单、pwa | + +目标:让公开工具箱在已访问并完成安装/缓存后可离线打开,JSON、Base64、URL、时间戳和正则工具仍在浏览器/Worker 内运行。 + +非目标:不离线写入评论或文章,不缓存 API 响应、Cookie、Session、Studio、预览链接、完整文章正文、用户输入或第三方任意脚本,不实现后台同步。 + +验收标准: + +- Service Worker scope 和缓存白名单只覆盖公开工具箱静态资源及明确允许的页面壳。 +- 首次安装、更新失败、缓存损坏、离线未命中和浏览器不支持时都有在线/无 JS 可读回退。 +- 新版本使用可观测的 cache key,旧缓存可控清理;更新期间不删除当前可用版本。 +- 工具输入不写入 Cache Storage、IndexedDB 或日志;Worker 继续遵守既有输入上限和 250ms 正则超时。 +- Lighthouse/Playwright 可验证安装元数据、离线工具路由、缓存边界和在线恢复。 + +测试场景:首次访问、离线打开 /tools、各五个工具、刷新、更新、缓存损坏、网络恢复、API/Studio 不被拦截、无 JavaScript、Save-Data 和浏览器不支持 Service Worker。 + +### S5-08:性能、生命周期与降级收口 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P0 | +| 前置依赖 | S5-02~S5-07 | +| 工作量 | 10–16 小时 | +| 公开接口 | 不新增;验证前述契约的失败和缓存语义 | +| 数据结构/Flag | 降级决策表、资源预算、dispose 清单 | + +目标:把每个重型能力的进入条件、资源预算、关闭路径、异常路径和可观测结果收敛为一套可重复规则。 + +非目标:不做 30 分钟压测、不修改生产容器上限、不引入监控平台、不把“浏览器能运行”当作内存和 CPU 通过。 + +验收标准: + +- 文章初始 JS 继续不超过 180KB gzip;Three.js、图谱、音乐可视化、PWA 安装逻辑不进入文章初始闭包。 +- 360/768/1280/1600px、Chrome Chromium E2E、键盘、触摸、无 JS、Save-Data、reduced-motion 均有自动化或明确手工记录。 +- 每个模块有 onMounted/onBeforeUnmount 或等价清理:AbortController、Observer、RAF、Timer、Worker、AudioContext、Three.js 和缓存更新监听均可重复进入退出。 +- Flag 关闭、运行时能力不足、动态资源失败、接口 5xx 和超时都回到静态可读路径,不出现死按钮、空面板或永久 loading。 +- 页面性能、可访问性和 SEO 不因阶段五效果低于既有阶段四门槛;阶段六再进行 30 分钟资源压测。 + +测试场景:路由快速往返、并发加载图谱/首页/音乐、动态块失败、资源加载超时、低端/移动 viewport、A11y tree、文章 bundle、Lighthouse、控制台未处理异常和缓存清理。 + +### S5-09:综合验收与文档 + +| 属性 | 内容 | +| --- | --- | +| 优先级 | P0,阶段收口 | +| 前置依赖 | S5-08 | +| 工作量 | 8–12 小时 | +| 公开接口 | OpenAPI、Nuxt manifest、Service Worker 与现有公开接口全部复核 | +| 数据结构/Flag | 固定验收数据、Flag 矩阵、降级矩阵、结果记录 | + +目标:将阶段五的公开契约、视觉路径、资源预算、降级和生命周期固化为可重复的本地验收,并同步真实文档命令。 + +非目标:不把本地通过写成远端 CI、真实 OSS/SMTP、备案、部署、生产压测或 AI 通过;不提交、推送或创建 PR。 + +验收标准: + +- 图谱契约与生成客户端无差异;所有新增后端接口均有 Problem Details、ETag/缓存和边界测试。 +- 首页三幕、终端主题、音乐频谱、404、PWA 均有主路径、关闭开关和失败降级路径。 +- 文章 SSR、工具离线、终端安全、音频授权、Service Worker 缓存边界和资源释放均可验证。 +- README、完整开发计划、本地启动指南和阶段五文档只记录真实存在的命令和真实状态;本阶段没有业务实现时不提前勾选完成。 +- git diff --check 通过,未授权的用户改动未被覆盖、恢复、提交或删除。 + +## 六、阶段五测试与验收计划 + +### 6.1 契约与单元测试 + +- API:图谱节点/边截断、公开可见性、稳定 ETag、Feature Flag 默认值、非法 manifest 配置和安全路径校验。 +- Web:终端 parser、主题状态、图谱数据归一化、音乐 manifest 校验、播放器状态机、频谱降级、404 状态机、PWA cache allowlist 和更新策略。 +- OpenAPI:生成 packages/api-client 后执行 check,禁止在 apps/web 手写重复 DTO。 + +### 6.2 浏览器与 SSR 场景 + +- 无 JavaScript打开首页、文章、404 和工具页,标题、正文/工具说明、错误路径和主要导航仍可用。 +- 首次进入首页不请求 Three.js;滚动进入第二幕后才加载,并在离开时释放。 +- 关闭所有阶段五 flags 后页面退回阶段四可用体验,不产生空白区域或报错。 +- 访问 360px、768px、1280px、1600px;键盘完成 Dock、终端、主题、播放器、图谱和 404 操作;触摸不依赖 hover。 +- Save-Data 和 prefers-reduced-motion 下不运行不必要动画;Lighthouse/控制台无回归。 +- 音乐只在用户手势后发起请求;路由切换不泄漏分析器;PWA 离线只支持公开工具箱。 + +### 6.3 安全和资源场景 + +- 终端命令注入、URL 穿越、超长输入、恶意搜索词和 XSS 字符不执行。 +- 图谱节点/链接、manifest 的音频/封面地址和 Service Worker scope 均执行协议、host、长度和数量限制。 +- 文章初始客户端闭包不含 Three.js、图谱重型依赖、编辑器、Worker 或音频分析代码。 +- 动态导入失败、WebGL/Web Audio 不可用、接口 5xx、缓存损坏、第三方资源超时都能回退。 + +## 七、迭代安排与工作量 + +按个人每周 15–20 小时估算,阶段五约 78–122 个理想开发小时,实际取决于图谱可视化和 Three.js 场景的复杂度。建议按以下顺序推进: + +| 迭代 | 任务 | 交付结果 | +| --- | --- | --- | +| 迭代 0 | S5-00 | 阶段准入、基线、任务文档和未执行项目清单 | +| 迭代 1 | S5-01、S5-02 | 图谱契约、开关和静态/桌面图谱闭环 | +| 迭代 2 | S5-03、S5-04 | 终端主题和首页三幕,含 Three.js 关闭回退 | +| 迭代 3 | S5-05、S5-06、S5-07 | 音乐频谱、404 和工具箱最小离线能力 | +| 迭代 4 | S5-08、S5-09 | 生命周期、性能降级、综合验收和文档收口 | + +并行规则:S5-03~S5-07 可以并行,但任何模块进入综合验收前都必须实现对应的关闭开关、无 JS/能力失败回退和卸载清理;没有这些路径,不得以“主路径可用”提前完成。 + +## 八、阶段五最终完成定义 + +阶段五只有同时满足以下条件才算完成: + +- S5-00~S5-09 的任务验收记录完整,且阶段文档、README/启动指南中的命令与真实脚本一致。 +- 知识星图使用受限公开契约,桌面交互可用,移动端和能力不足时退化为时间线;未公开数据和全文不泄露。 +- 终端是浏览器内白名单解析器,主题切换使用 html data-theme;无任何服务器 Shell 或 AI 请求越权。 +- 首页三幕首幕和日志 SSR 可读,Three.js 仅按需加载、文章包外置、离开路由彻底释放,失败时有静态回退。 +- 音乐来自服务端配置的受信任 OSS/CDN JSON 清单,默认不自动播放,音频授权信息可见,频谱可关闭且有 Web Audio 降级。 +- 404 游戏不阻塞错误恢复;PWA 只缓存公开离线工具箱,不缓存私有数据和 API 写入。 +- 360/768/1280/1600px、键盘、触摸、无 JS、Save-Data、reduced-motion、动态资源失败和路由往返通过。 +- 文章初始 JS 不超过 180KB gzip,既有 API/Web/OpenAPI/Compose 基线不回归;阶段六的 30 分钟压力测试和生产资源验收仍单独记录。 +- 本地验收通过不等于真实 SMTP、真实 OSS、域名/备案、部署、远端 CI/生产或 AI/RAG 通过;这些项目在报告中继续明确列为未执行或后续阶段。 + +本迭代完成定义:仅新增本文件和阶段五准入清单;没有业务代码、空类、数据库迁移、OpenAPI 修改、生成客户端改动、提交、推送、PR 或部署。 diff --git "a/docs/taget/\351\230\266\346\256\265\344\272\224\345\207\206\345\205\245\346\270\205\345\215\225.md" "b/docs/taget/\351\230\266\346\256\265\344\272\224\345\207\206\345\205\245\346\270\205\345\215\225.md" new file mode 100644 index 0000000..206e180 --- /dev/null +++ "b/docs/taget/\351\230\266\346\256\265\344\272\224\345\207\206\345\205\245\346\270\205\345\215\225.md" @@ -0,0 +1,140 @@ +# 阶段五准入清单:非 AI 体验与本地收口 + +## 0. 本轮结论 + +本清单对应“阶段 5 迭代 0:阶段准入与规划文档落盘”。本轮只建立可追溯基线和任务文档,不实现阶段五业务功能。 + +- 本轮文档:docs/taget/第五阶段目标任务.md、本文件。 +- 未新增业务代码、空类、数据库迁移、OpenAPI 路径、生成客户端或外部资源。 +- 未提交、推送、创建 PR、部署或修改云资源。 +- 阶段四文档在当前工作树中已经是删除状态;本轮未恢复,阶段四基线从提交 f0bfdac 读取。 + +## 一、Git 基线与用户资产 + +| 项目 | 本轮记录 | +| --- | --- | +| 工作目录 | G:\work\HaoBlog | +| 分支 | codex/s4-01,跟踪 origin/codex/s4-01 | +| HEAD | f0bfdac58e0f9821dd313e879892302eb971bc08(完成阶段四综合验收并收敛项目说明) | +| 最近提交 | f0bfdac、c246517、0b3b5b8、57a03a4、1b066e3、4330ffc、6c24570、a18a7de | +| 阶段五目录 | 本轮前不存在,已创建 docs/taget;该目录名按仓库规则保留为 taget | +| 工作树初始状态 | AGENTS.md 修改;docs/HaoBlog 完整开发计划.md 修改;docs/第四阶段目标任务.md 删除;docs/阶段四准入清单.md 删除;无未跟踪文件 | +| 保留资产 | 上述用户修改全部保留,尤其是完整开发计划中的阶段五边界、PWA/音乐/彩蛋调整;不覆盖、不回退、不恢复删除文档 | + +阶段 2/3 目标文档在当前树不存在:阶段 2 原文从 df6fada68e5fc1ecaabe257115bbfca95d6a20eb:docs/第二阶段目标任务.md 读取,阶段 3 原文从 7a377ab19fec7b829f47308662bb1b981e6fa460:docs/第三阶段目标任务.md 读取。阶段 4 原文从 f0bfdac 的 docs/第四阶段目标任务.md 和 docs/阶段四准入清单.md 读取。阶段 2/3 的历史实际路径在 docs/ 根目录,不在当前工作树的 docs/taget。 + +## 二、阶段四基线 + +阶段四收口文档记录的本地 S4-10 基线如下;这些数字是历史基线,必须以本轮实际命令结果为准,不能替代真实生产或远端 CI 验收: + +- 环境:Windows 11、Java 21.0.11、Node.js 24.14.0、pnpm 11.16.0、Docker Engine 29.7.2、Compose 5.3.1。 +- API:-DskipITs verify Surefire 78/78;PostgreSQL/pgvector Failsafe 53/53。 +- Web:typecheck、Vitest 85/85、Nuxt production build 通过;Chromium Playwright 14/14。 +- OpenAPI:generate、check 和生成文件无差异通过。 +- 文章初始客户端静态闭包 65,244 bytes gzip,小于 180 KiB;Mermaid 位于异步 chunk。 +- Lighthouse:文章页和工具页移动 360×800 的 Performance、Accessibility、SEO 均为 1.00。 +- 生产 Compose 上限:Caddy 64 MiB/0.2 CPU/64 PID,Web 320 MiB/0.6 CPU/128 PID,API 640 MiB/1.25 CPU/256 PID,PostgreSQL 480 MiB/0.8 CPU/128 PID。 +- 未完成的外部项目:真实 SMTP、真实 OSS、域名/HTTPS、备案、ACR/部署、远端 CI/生产、备份恢复和合规材料。 + +阶段五继承的本地硬门槛是:公共 SSR 不回归;文章初始 JS ≤180KB gzip;没有 Three.js 进入文章包;工具、评论、搜索、OpenAPI、Compose 和现有安全边界不回归。30 分钟压测、生产资源运行态和真实外部项目不在本轮通过范围。 + +## 三、本轮实际环境 + +| 命令 | 实际结果 | 状态 | +| --- | --- | --- | +| java -version | OpenJDK 21.0.11 LTS(2026-04-21) | 通过 | +| node --version | v24.14.0 | 通过 | +| corepack pnpm --version | 11.16.0 | 通过 | +| docker version | Client/Server 29.7.2,Docker Desktop 4.86.0 | 通过 | +| docker compose version | v5.3.1 | 通过 | + +以上五条环境命令已实际执行;其余基线命令在文档落盘后按下一节执行并回填结果。pnpm 命令的 Corepack 激活提示不影响最终版本为 11.16.0。 + +## 四、阶段五预定验收命令 + +以下命令是本轮预定执行的本地基线,不表示所有阶段五功能已经存在。命令均从仓库根目录执行;API 命令使用实际存在的 apps/api/mvnw.cmd。 + +### 4.1 工具链与 API/Web/OpenAPI + +~~~powershell +java -version +node --version +corepack pnpm --version +docker version +docker compose version + +Push-Location apps/api +.\mvnw.cmd -DskipITs 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 check +~~~ + +本轮不执行 generate,因为没有 OpenAPI 修改;后续阶段五若实现 S5-01 的 API,必须先执行 corepack pnpm --filter @haoblog/api-client generate,再执行 check 并审阅生成文件 diff。 + +### 4.2 Compose 与工作树 + +~~~powershell +docker compose --env-file .env.example -f infra/compose/compose.dev.yml config +docker compose --env-file infra/compose/.env.ci.example -f infra/compose/compose.prod.yml config +corepack pnpm compose:verify +git diff --check +~~~ + +本轮不启动 Compose、不构建镜像、不创建测试 volume;config 和 compose:verify 只验证解析、资源限制、健康边界和端口约束。 + +## 五、基线实际执行结果 + +本节在命令运行结束后回填。状态只允许使用“通过”“阻塞”“未执行”,不能用历史阶段结果代替本轮结果。 + +| 命令 | 结果 | 状态/阻塞原因 | +| --- | --- | --- | +| java -version | 21.0.11 LTS | 通过 | +| node --version | v24.14.0 | 通过 | +| corepack pnpm --version | 11.16.0 | 通过 | +| docker version | Client/Server 29.7.2 | 通过 | +| docker compose version | v5.3.1 | 通过 | +| apps/api/.\\mvnw.cmd -DskipITs verify | Surefire 78/78,BUILD SUCCESS | 通过 | +| corepack pnpm --dir apps/web typecheck | nuxt prepare、vue-tsc 完成 | 通过 | +| corepack pnpm --dir apps/web test | 19 个测试文件,85/85 | 通过 | +| corepack pnpm --dir apps/web build | Nuxt client/server/Nitro build complete | 通过;有现有依赖 deprecation 和 Vite 大 chunk 警告 | +| corepack pnpm --filter @haoblog/api-client check | openapi-typescript 7.13.0 check 完成 | 通过 | +| dev Compose config | Compose 配置成功解析 | 通过 | +| prod Compose config | Compose 配置成功解析 | 通过 | +| corepack pnpm compose:verify | Compose resource and health boundaries passed | 通过 | +| git diff --check | exit code 0;仅有现有 CRLF 提示 | 通过 | + +## 六、阶段五准入判断 + +开始 S5-01 业务实现前,必须满足: + +- 本文件和 docs/taget/第五阶段目标任务.md 已存在且描述与用户锁定决策一致。 +- API -DskipITs verify、Web typecheck/test/build、OpenAPI check、两套 Compose config、compose:verify 和 git diff --check 均通过;阻塞项必须有环境证据和替代验证,不得口头豁免。 +- 工作树中的 AGENTS.md 和完整开发计划修改仍保持用户原文;阶段四删除文档不因本轮被恢复。 +- 开始业务实现时先从 S5-01 契约和 Feature Flag 入手,再实现图谱与体验组件;不直接创建空类或占位 API。 +- 任何新增依赖先说明当前栈不足、路由懒加载方式和 bundle/runtime 成本;优先 CSS、浏览器原生 API 和现有 Vue 能力。 + +## 七、未执行外部项目与剩余风险 + +以下项目本轮明确未执行,不能在阶段五本地文档通过后宣称完成: + +- 远端 GitHub Actions/CI、镜像推送、ACR、部署和远端生产验收。 +- 真实 OSS/CDN 音乐清单、真实 OSS 媒体上传、真实 SMTP 投递。 +- 真实域名、HTTPS、ICP/公安备案、隐私/评论/AI 合规材料。 +- 数据库备份上传、解密、恢复和 RPO/RTO 演练。 +- 阶段六的 30 分钟 2GB 压测、持续 Swap、OOM、容器资源运行态和回滚演练。 +- AI 基础、SSE、RAG、Embedding、模型供应商、费用和 AI 安全评测。 + +剩余风险:图谱/Three.js/音频分析会增加浏览器内存和首屏后交互成本;低端移动设备和 Safari 的 WebGL/Web Audio 行为需在 S5-08 实机或等价浏览器路径验证;PWA 更新和缓存边界一旦配置错误可能持有旧资源或拦截 API;音乐授权和第三方 CDN 可用性需要真实来源确认;阶段四文档当前不在工作树,后续引用应保持提交来源可追溯。 + +## 八、本轮完成定义 + +- [x] 读取并记录 AGENTS、完整开发计划、阶段 2/3 历史任务、阶段 4 收口文档、设计语言、README 和本地启动指南。 +- [x] 读取并使用 .agents/skills/haoblog-design/SKILL.md 的“仪器而非装饰、降级是一等路径”原则。 +- [x] 记录 Git 分支、HEAD、最近提交、用户未提交修改、阶段四删除状态和未跟踪文件检查。 +- [x] 创建阶段五目标任务和本准入清单。 +- [x] 完成本节“基线实际执行结果”的所有命令并逐项记录。 +- [ ] 阶段五业务功能完成;本轮明确不做。 From f6f1972ffc61b62d48adcd1d2b73b4df0aa87c4f Mon Sep 17 00:00:00 2001 From: DDT <––1786035110@stu.gpnu.edu.cn> Date: Sun, 23 Aug 2026 21:42:32 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=BA=94=E8=BF=AD=E4=BB=A3=E4=B8=80=E5=9B=BE=E8=B0=B1=E4=B8=8E?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .../content/application/ArticleService.java | 49 +++ .../ArticleRevisionRepository.java | 32 ++ .../site/application/GardenGraphService.java | 254 +++++++++++++++ .../site/application/PublicFeedService.java | 4 +- .../haoblog/site/application/SiteService.java | 87 ++++- .../io/haoblog/site/domain/SiteSetting.java | 6 + .../haoblog/site/web/AdminSiteController.java | 10 +- .../site/web/PublicGardenController.java | 65 ++++ .../site/web/PublicSiteController.java | 13 +- .../application/ToolManagementService.java | 18 + .../src/main/resources/application-prod.yml | 1 + apps/api/src/main/resources/application.yml | 1 + .../migration/V16__site_experience_flags.sql | 3 + .../test/java/io/haoblog/AdminCommentIT.java | 27 +- .../test/java/io/haoblog/ContentModelIT.java | 2 +- .../test/java/io/haoblog/GardenGraphIT.java | 110 +++++++ .../src/test/java/io/haoblog/PublicApiIT.java | 2 +- .../test/java/io/haoblog/PublicApiTest.java | 21 +- .../application/GardenGraphServiceTest.java | 54 +++ .../application/PublicFeedServiceTest.java | 2 +- .../site/application/SiteServiceTest.java | 35 ++ .../components/garden/GardenCanvas.client.vue | 308 ++++++++++++++++++ apps/web/app/pages/garden.vue | 127 +++++++- apps/web/app/pages/studio/settings.vue | 37 ++- apps/web/app/pages/tools.vue | 18 +- apps/web/app/utils/publicGarden.ts | 32 ++ apps/web/app/utils/publicSite.ts | 3 + apps/web/e2e/stage5.spec.ts | 86 +++++ apps/web/nuxt.config.ts | 1 - apps/web/package.json | 2 + apps/web/test/public-garden.test.ts | 13 + docs/openapi/public-api.yaml | 59 +++- ...56\346\240\207\344\273\273\345\212\241.md" | 28 +- ...06\345\205\245\346\270\205\345\215\225.md" | 30 +- ...57\345\212\250\346\214\207\345\215\227.md" | 2 +- infra/compose/.env.ci.example | 1 + infra/compose/compose.dev.yml | 1 + infra/compose/compose.prod.yml | 1 + packages/api-client/src/generated.ts | 81 +++++ pnpm-lock.yaml | 6 + 41 files changed, 1575 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java create mode 100644 apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java create mode 100644 apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql create mode 100644 apps/api/src/test/java/io/haoblog/GardenGraphIT.java create mode 100644 apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java create mode 100644 apps/web/app/components/garden/GardenCanvas.client.vue create mode 100644 apps/web/app/utils/publicGarden.ts create mode 100644 apps/web/e2e/stage5.spec.ts create mode 100644 apps/web/test/public-garden.test.ts diff --git a/.env.example b/.env.example index 19d999d..5e30406 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java index 958ca94..38b8e82 100644 --- a/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java +++ b/apps/api/src/main/java/io/haoblog/content/application/ArticleService.java @@ -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; @@ -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 listPublicGardenArticles() { + Map 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"); @@ -111,7 +128,39 @@ public UUID articleId() { public record PageResult(Page page) {} public record PublishedBatch(List 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 tags) {} public record SearchPage(String query, Page 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 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)); + } + } } diff --git a/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java b/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java index 8017597..228ee64 100644 --- a/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java +++ b/apps/api/src/main/java/io/haoblog/content/persistence/ArticleRevisionRepository.java @@ -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 { boolean existsByArticleId(UUID articleId); @@ -100,6 +101,25 @@ where a.status in ('PUBLISHED', 'SCHEDULED') Page 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 findPublicGardenRows(@Param("now") Instant now); + @Query(""" select r as revision, a.publishedAt as publishedAt, a.commentsEnabled as commentsEnabled from ArticleRevision r, Article a @@ -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(); + } } diff --git a/apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java b/apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java new file mode 100644 index 0000000..8da371b --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/site/application/GardenGraphService.java @@ -0,0 +1,254 @@ +package io.haoblog.site.application; + +import io.haoblog.content.application.ArticleService; +import io.haoblog.toolbox.application.ToolManagementService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class GardenGraphService { + static final int MAX_NODES = 200; + static final int MAX_EDGES = 600; + + private final ArticleService articles; + private final ToolManagementService tools; + + public GardenGraphService(ArticleService articles, ToolManagementService tools) { + this.articles = articles; + this.tools = tools; + } + + @Transactional(readOnly = true) + public Graph get() { + return build(articles.listPublicGardenArticles(), tools.listPublicGardenTools()); + } + + public static Graph build(List articles, + List tools) { + Map nodes = new LinkedHashMap<>(); + Map edges = new LinkedHashMap<>(); + + for (var article : articles == null ? List.of() : articles) { + String articleId = nodeId(NodeType.ARTICLE, article.slug()); + if (articleId == null) continue; + addNode(nodes, new CandidateNode(articleId, NodeType.ARTICLE, cleanLabel(article.title(), article.slug()), + "/articles/" + article.slug(), cleanText(article.excerpt()), article.publishedAt(), 0)); + addMemberships(nodes, edges, articleId, article.category(), article.tags()); + addCoOccurrences(edges, article.tags()); + } + for (var tool : tools == null ? List.of() : tools) { + String toolId = nodeId(NodeType.TOOL, tool.slug()); + if (toolId == null) continue; + addNode(nodes, new CandidateNode(toolId, NodeType.TOOL, cleanLabel(tool.title(), tool.slug()), + "/tools?tool=" + tool.slug(), cleanText(tool.description()), null, tool.sortOrder())); + addMemberships(nodes, edges, toolId, + taxonomy(tool.categoryName(), tool.categorySlug()), + tool.tags() == null ? List.of() : tool.tags().stream() + .map(value -> taxonomy(value, value)).toList()); + addCoOccurrences(edges, tool.tags() == null ? List.of() : tool.tags().stream() + .map(value -> taxonomy(value, value)).toList()); + } + + edges.values().forEach(edge -> { + CandidateNode source = nodes.get(edge.source()); + CandidateNode target = nodes.get(edge.target()); + if (source != null) source.fullDegree += edge.weight(); + if (target != null) target.fullDegree += edge.weight(); + }); + + List orderedNodes = nodes.values().stream().sorted(NODE_ORDER).toList(); + Set selectedIds = orderedNodes.stream().limit(MAX_NODES) + .map(CandidateNode::id).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + List orderedEdges = edges.values().stream() + .filter(edge -> selectedIds.contains(edge.source()) && selectedIds.contains(edge.target())) + .sorted(EDGE_ORDER) + .toList(); + List selectedEdges = orderedEdges.stream().limit(MAX_EDGES).toList(); + Map degrees = new HashMap<>(); + for (var edge : selectedEdges) { + degrees.merge(edge.source(), 1, Integer::sum); + degrees.merge(edge.target(), 1, Integer::sum); + } + List resultNodes = orderedNodes.stream().limit(MAX_NODES) + .map(node -> new Node(node.id(), node.type(), node.label(), node.href(), node.summary(), + node.publishedAt(), degrees.getOrDefault(node.id(), 0))) + .toList(); + List resultEdges = selectedEdges.stream() + .map(edge -> new Edge(edge.source(), edge.target(), edge.kind(), edge.weight())) + .toList(); + boolean truncated = resultNodes.size() < nodes.size() || resultEdges.size() < orderedEdges.size() + || resultEdges.size() < edges.size(); + return new Graph(resultNodes, resultEdges, truncated); + } + + private static void addMemberships(Map nodes, Map edges, + String contentId, ArticleService.GardenTaxonomy category, + List tags) { + if (category != null) addMembership(nodes, edges, contentId, NodeType.CATEGORY, category); + Set seen = new LinkedHashSet<>(); + for (var tag : tags == null ? List.of() : tags) { + if (tag != null && seen.add(normalizeSlug(tag.slug()))) { + addMembership(nodes, edges, contentId, NodeType.TAG, tag); + } + } + } + + private static void addMembership(Map nodes, Map edges, + String contentId, NodeType type, ArticleService.GardenTaxonomy taxonomy) { + String taxonomyId = nodeId(type, taxonomy.slug()); + if (taxonomyId == null) return; + addNode(nodes, new CandidateNode(taxonomyId, type, cleanLabel(taxonomy.name(), taxonomy.slug()), + taxonomyHref(type, taxonomy.slug()), null, null, 0)); + addEdge(edges, contentId, taxonomyId, EdgeKind.MEMBERSHIP, 1); + } + + private static void addCoOccurrences(Map edges, + List sourceTags) { + List tags = sourceTags == null ? List.of() : sourceTags; + List ids = tags.stream().filter(tag -> tag != null && tag.slug() != null) + .map(tag -> nodeId(NodeType.TAG, tag.slug())).filter(java.util.Objects::nonNull).distinct().sorted().toList(); + for (int left = 0; left < ids.size(); left++) { + for (int right = left + 1; right < ids.size(); right++) { + addEdge(edges, ids.get(left), ids.get(right), EdgeKind.CO_OCCURRENCE, 1); + } + } + } + + private static void addEdge(Map edges, String source, String target, + EdgeKind kind, int weight) { + EdgeKey key = new EdgeKey(source, target, kind); + edges.merge(key, new CandidateEdge(source, target, kind, weight), + (left, right) -> new CandidateEdge(left.source(), left.target(), left.kind(), left.weight() + right.weight())); + } + + private static void addNode(Map nodes, CandidateNode candidate) { + nodes.merge(candidate.id(), candidate, GardenGraphService::preferNode); + } + + private static CandidateNode preferNode(CandidateNode left, CandidateNode right) { + String label = left.label().compareTo(right.label()) <= 0 ? left.label() : right.label(); + String summary = left.summary() != null ? left.summary() : right.summary(); + Instant publishedAt = left.publishedAt() != null ? left.publishedAt() : right.publishedAt(); + return new CandidateNode(left.id(), left.type(), label, left.href(), summary, publishedAt, + Math.min(left.sortOrder(), right.sortOrder())); + } + + private static final Comparator NODE_ORDER = (left, right) -> { + int type = Integer.compare(typeOrder(left.type()), typeOrder(right.type())); + if (type != 0) return type; + if (left.type() == NodeType.ARTICLE) { + int published = comparePublishedAt(left.publishedAt(), right.publishedAt()); + if (published != 0) return published; + } + if (left.type() == NodeType.TOOL) { + int sort = Integer.compare(left.sortOrder(), right.sortOrder()); + if (sort != 0) return sort; + } + if (left.type() == NodeType.TAG || left.type() == NodeType.CATEGORY) { + int degree = Integer.compare(right.fullDegree(), left.fullDegree()); + if (degree != 0) return degree; + } + return Comparator.comparing(CandidateNode::label).thenComparing(CandidateNode::id).compare(left, right); + }; + + private static final Comparator EDGE_ORDER = Comparator + .comparingInt(CandidateEdge::weight).reversed() + .thenComparing(edge -> edge.kind().name()) + .thenComparing(CandidateEdge::source) + .thenComparing(CandidateEdge::target); + + private static int typeOrder(NodeType type) { + return switch (type) { + case ARTICLE -> 0; + case TOOL -> 1; + case CATEGORY -> 2; + case TAG -> 3; + }; + } + + private static int comparePublishedAt(Instant left, Instant right) { + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + } + + private static String taxonomyHref(NodeType type, String slug) { + return "/articles?" + (type == NodeType.CATEGORY ? "category" : "tag") + "=" + slug; + } + + private static String nodeId(NodeType type, String rawSlug) { + String slug = normalizeSlug(rawSlug); + return slug == null ? null : type.name().toLowerCase(Locale.ROOT) + ":" + slug; + } + + private static String normalizeSlug(String rawSlug) { + if (rawSlug == null || rawSlug.isBlank()) return null; + return java.text.Normalizer.normalize(rawSlug, java.text.Normalizer.Form.NFKC).trim().toLowerCase(Locale.ROOT); + } + + private static String cleanLabel(String label, String fallback) { + return label == null || label.isBlank() ? fallback : label.trim(); + } + + private static String cleanText(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static ArticleService.GardenTaxonomy taxonomy(String name, String slug) { + if (slug == null || slug.isBlank()) return null; + return new ArticleService.GardenTaxonomy(cleanLabel(name, slug), slug); + } + + public enum NodeType { ARTICLE, TAG, CATEGORY, TOOL } + public enum EdgeKind { MEMBERSHIP, CO_OCCURRENCE } + + public record Node(String id, NodeType type, String label, String href, String summary, + Instant publishedAt, int degree) {} + public record Edge(String source, String target, EdgeKind kind, int weight) {} + public record Graph(List nodes, List edges, boolean truncated) {} + + private record EdgeKey(String source, String target, EdgeKind kind) {} + private record CandidateEdge(String source, String target, EdgeKind kind, int weight) {} + private static final class CandidateNode { + private final String id; + private final NodeType type; + private final String label; + private final String href; + private final String summary; + private final Instant publishedAt; + private final int sortOrder; + private int fullDegree; + + private CandidateNode(String id, NodeType type, String label, String href, String summary, + Instant publishedAt, int sortOrder) { + this.id = id; + this.type = type; + this.label = label; + this.href = href; + this.summary = summary; + this.publishedAt = publishedAt; + this.sortOrder = sortOrder; + } + + String id() { return id; } + NodeType type() { return type; } + String label() { return label; } + String href() { return href; } + String summary() { return summary; } + Instant publishedAt() { return publishedAt; } + int sortOrder() { return sortOrder; } + int fullDegree() { return fullDegree; } + } +} diff --git a/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java b/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java index 5ffd22b..4a70119 100644 --- a/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java +++ b/apps/api/src/main/java/io/haoblog/site/application/PublicFeedService.java @@ -23,7 +23,7 @@ public class PublicFeedService { private static final int RSS_LIMIT = 20; private static final int SITEMAP_BATCH_SIZE = 500; private static final int SITEMAP_URL_LIMIT = 50_000; - private static final int STATIC_URL_COUNT = 4; + private static final int STATIC_URL_COUNT = 5; private static final DateTimeFormatter RSS_DATE_FORMAT = DateTimeFormatter.RFC_1123_DATE_TIME; private final SiteService siteService; private final ArticleService articleService; @@ -104,7 +104,7 @@ private static String writeSitemap(SiteService.SiteResult site, List { writer.writeStartElement("urlset"); writer.writeDefaultNamespace("http://www.sitemaps.org/schemas/sitemap/0.9"); - for (String path : List.of("/", "/articles", "/tools", "/about")) { + for (String path : List.of("/", "/articles", "/garden", "/tools", "/about")) { urlElement(writer, url(site.siteUrl(), path)); } for (var article : articles) { 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 103d1a2..55f7ba7 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 @@ -2,9 +2,12 @@ import io.haoblog.site.persistence.SiteSettingRepository; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.net.URI; +import java.util.Arrays; +import java.util.Locale; @Service @@ -12,40 +15,71 @@ public class SiteService { private final SiteSettingRepository repository; private final String publicBaseUrl; private final String authorName; + private final String musicManifestUrl; + @Autowired public SiteService(SiteSettingRepository repository, @Value("${haoblog.site.public-base-url}") String publicBaseUrl, - @Value("${haoblog.site.author-name}") String authorName) { + @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; } public SiteResult get() { var setting = repository.findBySiteKey("default").orElseThrow(); - return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, setting.isCommentsEnabled()); + return new SiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, + setting.isCommentsEnabled(), setting.isMusicEnabled() && musicManifestUrl != null, + setting.isThreeDEnabled(), musicManifestUrl); } public AdminSiteResult getAdmin() { var setting = repository.findBySiteKey("default").orElseThrow(); return new AdminSiteResult(setting.getTitle(), setting.getDescription(), publicBaseUrl, authorName, - setting.isCommentsEnabled(), setting.getVersion()); + setting.isCommentsEnabled(), setting.isMusicEnabled(), setting.isThreeDEnabled(), setting.getVersion()); } @org.springframework.transaction.annotation.Transactional public AdminSiteResult updateCommentsEnabled(long expectedVersion, boolean commentsEnabled) { + return updateSettings(expectedVersion, commentsEnabled, null, null); + } + + @org.springframework.transaction.annotation.Transactional + public AdminSiteResult updateSettings(long expectedVersion, Boolean commentsEnabled, + Boolean musicEnabled, Boolean threeDEnabled) { 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()); } - setting.setCommentsEnabled(commentsEnabled); + boolean nextMusicEnabled = musicEnabled == null ? setting.isMusicEnabled() : musicEnabled; + if (nextMusicEnabled && musicManifestUrl == 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"); + } + 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.getVersion()); + saved.isCommentsEnabled(), saved.isMusicEnabled(), saved.isThreeDEnabled(), saved.getVersion()); } public long currentVersion() { @@ -70,12 +104,49 @@ static String normalizePublicBaseUrl(String raw) { return raw.trim().replaceFirst("/+$", ""); } - public record SiteResult(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { + static String normalizeMusicManifestUrl(String raw, String activeProfiles) { + if (raw == null || raw.isBlank()) return null; + String value = raw.trim(); + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("HAOBLOG_MUSIC_MANIFEST_URL must be an absolute HTTPS URL", exception); + } + boolean absoluteHost = uri.isAbsolute() && uri.getHost() != null && uri.getUserInfo() == null + && uri.getFragment() == null; + boolean https = "https".equalsIgnoreCase(uri.getScheme()); + boolean localProfile = Arrays.stream((activeProfiles == null ? "" : activeProfiles).split(",")) + .map(String::trim).map(valueProfile -> valueProfile.toLowerCase(Locale.ROOT)) + .anyMatch(profile -> profile.equals("local") || profile.equals("dev") || profile.equals("test")); + boolean localhostHttp = "http".equalsIgnoreCase(uri.getScheme()) && localProfile + && SetOfLocalHosts.contains(uri.getHost().toLowerCase(Locale.ROOT)); + if (!absoluteHost || (!https && !localhostHttp)) { + throw new IllegalArgumentException("HAOBLOG_MUSIC_MANIFEST_URL must be HTTPS; local/dev may use localhost HTTP"); + } + return value; + } + + private static final java.util.Set SetOfLocalHosts = java.util.Set.of("localhost", "127.0.0.1", "::1"); + + public record SiteResult(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, + String musicManifestUrl) { public SiteResult(String title, String description, String siteUrl, String authorName) { - this(title, description, siteUrl, authorName, true); + this(title, description, siteUrl, authorName, true, false, false, null); + } + public SiteResult(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled) { + this(title, description, siteUrl, authorName, commentsEnabled, false, false, null); } } public record AdminSiteResult(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, long version) {} + boolean commentsEnabled, boolean musicEnabled, 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); + } + } } 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 34366e1..777b822 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 @@ -12,6 +12,8 @@ public class SiteSetting { @Column(nullable = false, length = 160) private String title; @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 = "three_d_enabled", nullable = false) private boolean threeDEnabled = false; @Version @Column(nullable = false) private long version; protected SiteSetting() {} public SiteSetting(String siteKey, String title, String description) { @@ -20,6 +22,10 @@ public SiteSetting(String siteKey, String title, String description) { public String getTitle() { return title; } public String getDescription() { return description; } public boolean isCommentsEnabled() { return commentsEnabled; } + public boolean isMusicEnabled() { return musicEnabled; } + 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 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 9aff3df..2bb877e 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 @@ -28,20 +28,22 @@ public Response get() { @PutMapping public Response update(@RequestBody @Valid UpdateRequest request) { try { - return Response.from(service.updateCommentsEnabled(request.version(), request.commentsEnabled())); + return Response.from(service.updateSettings(request.version(), request.commentsEnabled(), + request.musicEnabled(), request.threeDEnabled())); } catch (OptimisticLockingFailureException exception) { throw new ProblemException("SITE_VERSION_CONFLICT", "Site setting version conflict", "Reload the latest site settings before saving", service.currentVersion()); } } - public record UpdateRequest(@NotNull Long version, @NotNull Boolean commentsEnabled) {} + public record UpdateRequest(@NotNull Long version, @NotNull Boolean commentsEnabled, + Boolean musicEnabled, Boolean threeDEnabled) {} public record Response(String title, String description, String siteUrl, String authorName, - boolean commentsEnabled, long version) { + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, long version) { static Response from(SiteService.AdminSiteResult result) { return new Response(result.title(), result.description(), result.siteUrl(), result.authorName(), - result.commentsEnabled(), result.version()); + result.commentsEnabled(), result.musicEnabled(), result.threeDEnabled(), result.version()); } } } diff --git a/apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java b/apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java new file mode 100644 index 0000000..c0de4d7 --- /dev/null +++ b/apps/api/src/main/java/io/haoblog/site/web/PublicGardenController.java @@ -0,0 +1,65 @@ +package io.haoblog.site.web; + +import io.haoblog.site.application.GardenGraphService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/public/garden") +public class PublicGardenController { + private final GardenGraphService service; + + public PublicGardenController(GardenGraphService service) { + this.service = service; + } + + @GetMapping + public ResponseEntity garden(@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { + Response response = Response.from(service.get()); + String etag = representationHash(response); + var headers = new HttpHeaders(); + headers.setETag(etag); + headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); + if (etag.equals(ifNoneMatch)) return ResponseEntity.status(304).headers(headers).build(); + return ResponseEntity.ok().headers(headers).body(response); + } + + private static String representationHash(Object value) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + return '"' + HexFormat.of().formatHex(digest.digest(value.toString().getBytes(StandardCharsets.UTF_8))) + '"'; + } catch (Exception exception) { + throw new IllegalStateException("Unable to create garden representation ETag", exception); + } + } + + public record Response(List nodes, List edges, boolean truncated) { + static Response from(GardenGraphService.Graph graph) { + return new Response(graph.nodes().stream().map(Node::from).toList(), + graph.edges().stream().map(Edge::from).toList(), graph.truncated()); + } + } + + public record Node(String id, GardenGraphService.NodeType type, String label, String href, + String summary, java.time.Instant publishedAt, int degree) { + static Node from(GardenGraphService.Node value) { + return new Node(value.id(), value.type(), value.label(), value.href(), value.summary(), + value.publishedAt(), value.degree()); + } + } + + public record Edge(String source, String target, GardenGraphService.EdgeKind kind, int weight) { + static Edge from(GardenGraphService.Edge value) { + return new Edge(value.source(), value.target(), value.kind(), value.weight()); + } + } +} 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 b2f6020..630e3ca 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 @@ -20,7 +20,8 @@ public class PublicSiteController { @GetMapping 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()); + var response = new SiteResponse(result.title(), result.description(), result.siteUrl(), result.authorName(), + result.commentsEnabled(), result.musicEnabled(), result.threeDEnabled(), result.musicManifestUrl()); var headers = new HttpHeaders(); headers.setETag(representationHash(response)); headers.setCacheControl("public, max-age=0, s-maxage=60, must-revalidate"); @@ -39,9 +40,15 @@ private static String representationHash(Object value) { } } - public record SiteResponse(String title, String description, String siteUrl, String authorName, boolean commentsEnabled) { + public record SiteResponse(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled, boolean musicEnabled, boolean threeDEnabled, + String musicManifestUrl) { public SiteResponse(String title, String description, String siteUrl, String authorName) { - this(title, description, siteUrl, authorName, true); + this(title, description, siteUrl, authorName, true, false, false, null); + } + public SiteResponse(String title, String description, String siteUrl, String authorName, + boolean commentsEnabled) { + this(title, description, siteUrl, authorName, commentsEnabled, false, false, null); } } } diff --git a/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java index c5567a1..6549318 100644 --- a/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java +++ b/apps/api/src/main/java/io/haoblog/toolbox/application/ToolManagementService.java @@ -53,6 +53,21 @@ public PublicTools listPublicTools(String category, ToolType type, String keywor return queryPublicTools(normalizedCategory, type, normalizedKeyword); } + @Transactional(readOnly = true) + public List listPublicGardenTools() { + List activeTools = tools.findAllByStatus(ToolStatus.ACTIVE, + Sort.by(Sort.Direction.ASC, "sortOrder", "title", "id")); + Map categoryById = categories.findAllById( + activeTools.stream().map(Tool::getCategoryId).distinct().toList()).stream() + .collect(Collectors.toMap(ToolCategory::getId, categoryValue -> categoryValue)); + return activeTools.stream().map(tool -> { + ToolCategory category = categoryById.get(tool.getCategoryId()); + if (category == null) return null; + return new GardenTool(tool.getId(), tool.getTitle(), tool.getSlug(), tool.getDescription(), + category.getName(), category.getSlug(), tool.getTags(), tool.getSortOrder()); + }).filter(java.util.Objects::nonNull).toList(); + } + private PublicTools queryPublicTools(String normalizedCategory, ToolType type, String normalizedKeyword) { List activeTools = tools.findAllByStatus(ToolStatus.ACTIVE, @@ -235,4 +250,7 @@ static PublicTool from(Tool tool, ToolCategory category) { tool.getTags(), tool.getSortOrder()); } } + + public record GardenTool(UUID id, String title, String slug, String description, + String categoryName, String categorySlug, List tags, int sortOrder) {} } diff --git a/apps/api/src/main/resources/application-prod.yml b/apps/api/src/main/resources/application-prod.yml index 3700f2c..f384473 100644 --- a/apps/api/src/main/resources/application-prod.yml +++ b/apps/api/src/main/resources/application-prod.yml @@ -8,5 +8,6 @@ server: haoblog: site: public-base-url: ${HAOBLOG_PUBLIC_BASE_URL} + music-manifest-url: ${HAOBLOG_MUSIC_MANIFEST_URL:} security: session-cookie-secure: true diff --git a/apps/api/src/main/resources/application.yml b/apps/api/src/main/resources/application.yml index 0cfb731..63aea3a 100644 --- a/apps/api/src/main/resources/application.yml +++ b/apps/api/src/main/resources/application.yml @@ -73,6 +73,7 @@ haoblog: site: public-base-url: ${HAOBLOG_PUBLIC_BASE_URL:http://localhost:3000} author-name: ${HAOBLOG_AUTHOR_NAME:Hao} + music-manifest-url: ${HAOBLOG_MUSIC_MANIFEST_URL:} security: session-cookie-secure: ${HAOBLOG_SESSION_COOKIE_SECURE:false} seed: diff --git a/apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql b/apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql new file mode 100644 index 0000000..be3ea8f --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V16__site_experience_flags.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_setting + ADD COLUMN music_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN three_d_enabled boolean NOT NULL DEFAULT false; diff --git a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java index f09f095..6735984 100644 --- a/apps/api/src/test/java/io/haoblog/AdminCommentIT.java +++ b/apps/api/src/test/java/io/haoblog/AdminCommentIT.java @@ -65,7 +65,7 @@ static void database(DynamicPropertyRegistry registry) { @BeforeEach void seed() { jdbc.execute("TRUNCATE comment, article_revision, article CASCADE"); - jdbc.update("UPDATE site_setting SET comments_enabled=true, version=0 WHERE site_key='default'"); + jdbc.update("UPDATE site_setting SET comments_enabled=true, music_enabled=false, three_d_enabled=false, version=0 WHERE site_key='default'"); Instant now = Instant.parse("2020-01-01T00:00:00Z"); Article article = articles.saveAndFlush(new Article("moderation-signal", "Moderation signal", "Excerpt", "# body", ArticleStatus.PUBLISHED, now.minus(1, ChronoUnit.DAYS), now)); @@ -151,6 +151,31 @@ void globalSiteSwitchChangesPublicSiteAndArticleEtags() throws Exception { .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SITE_VERSION_CONFLICT")); } + @Test + void protectsExperienceFlagsAndRequiresMusicManifest() throws Exception { + mvc.perform(get("/api/v1/admin/site")) + .andExpect(status().isUnauthorized()); + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true,\"musicEnabled\":true,\"threeDEnabled\":false}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("MUSIC_MANIFEST_NOT_CONFIGURED")); + + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true,\"musicEnabled\":false,\"threeDEnabled\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.threeDEnabled").value(true)) + .andExpect(jsonPath("$.version").value(1)); + mvc.perform(get("/api/v1/public/site")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.musicEnabled").value(false)) + .andExpect(jsonPath("$.threeDEnabled").value(true)) + .andExpect(jsonPath("$.musicManifestUrl").isEmpty()); + mvc.perform(put("/api/v1/admin/site").with(admin()).with(csrf()).contentType("application/json") + .content("{\"version\":0,\"commentsEnabled\":true,\"musicEnabled\":false,\"threeDEnabled\":false}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("SITE_VERSION_CONFLICT")); + } + @Test void articleSwitchChangesPublicArticleEtag() throws Exception { var before = mvc.perform(get("/api/v1/public/articles/moderation-signal")).andExpect(status().isOk()).andReturn(); diff --git a/apps/api/src/test/java/io/haoblog/ContentModelIT.java b/apps/api/src/test/java/io/haoblog/ContentModelIT.java index 7ca0255..c9c14d2 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(15, jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history", Integer.class)); + assertEquals(16, 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( diff --git a/apps/api/src/test/java/io/haoblog/GardenGraphIT.java b/apps/api/src/test/java/io/haoblog/GardenGraphIT.java new file mode 100644 index 0000000..0a2ca63 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/GardenGraphIT.java @@ -0,0 +1,110 @@ +package io.haoblog; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +class GardenGraphIT { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("pgvector/pgvector:0.8.6-pg17"); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.placeholders.admin_username", () -> "admin"); + registry.add("spring.flyway.placeholders.admin_password_hash", () -> "$2a$10$0V.Xs7CLOUYSekm7RKq3Z.iY76KUan/Xbeu5vjmLpX.sVd4pcFpIu"); + } + + @Autowired MockMvc mvc; + @Autowired JdbcTemplate jdbc; + + @BeforeEach + void clean() { + jdbc.execute("TRUNCATE article_tag, article_preview_token, article_revision, article, category, tag, tool, tool_category CASCADE"); + } + + @Test + void exposesOnlyPublicSnapshotsActiveToolsAndStableConditionalResponse() throws Exception { + Instant now = Instant.parse("2026-08-23T00:00:00Z"); + UUID categoryId = UUID.randomUUID(); + jdbc.update("INSERT INTO category(id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + categoryId, "Backend", "backend", Timestamp.from(now), Timestamp.from(now)); + seedArticle("public-signal", "Public signal", "Published excerpt", "# secret markdown", "PUBLISHED", now.minusSeconds(60), + categoryId, "[{\"name\":\"Java\",\"slug\":\"java\"},{\"name\":\"Spring\",\"slug\":\"spring\"}]", now); + seedArticle("future-signal", "Future signal", "Future", "future markdown", "SCHEDULED", now.plusSeconds(60), + null, "[]", now); + seedArticle("draft-signal", "Draft signal", "Draft", "draft markdown", "DRAFT", null, + null, "[]", now); + seedArticle("archived-signal", "Archived signal", "Archived", "archived markdown", "ARCHIVED", now.minusSeconds(120), + null, "[]", now); + + UUID toolCategoryId = UUID.randomUUID(); + UUID toolId = UUID.randomUUID(); + jdbc.update("INSERT INTO tool_category(id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + toolCategoryId, "Utilities", "utilities", Timestamp.from(now), Timestamp.from(now)); + jdbc.update("INSERT INTO tool(id, category_id, type, status, title, slug, description, url, tags, created_at, updated_at) " + + "VALUES (?, ?, 'LINK', 'ACTIVE', ?, ?, ?, ?, ?::jsonb, ?, ?)", + toolId, toolCategoryId, "JSON Tool", "json-tool", "Format JSON", "https://external.example/tool", + "[\"Java\",\"Spring\"]", Timestamp.from(now), Timestamp.from(now)); + + var first = mvc.perform(get("/api/v1/public/garden")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes.length()").value(6)) + .andExpect(jsonPath("$.edges[?(@.kind == 'CO_OCCURRENCE')].weight").value(org.hamcrest.Matchers.contains(2))) + .andExpect(jsonPath("$.truncated").value(false)) + .andExpect(header().exists("ETag")) + .andReturn(); + String body = first.getResponse().getContentAsString(); + assertTrue(body.contains("/tools?tool=json-tool")); + assertFalse(body.contains("secret markdown")); + assertFalse(body.contains("https://external.example/tool")); + assertFalse(body.contains("future-signal")); + assertFalse(body.contains("draft-signal")); + assertFalse(body.contains("archived-signal")); + + mvc.perform(get("/api/v1/public/garden").header("If-None-Match", first.getResponse().getHeader("ETag"))) + .andExpect(status().isNotModified()).andExpect(header().exists("ETag")); + } + + private void seedArticle(String slug, String title, String excerpt, String markdown, String status, + Instant publishedAt, UUID categoryId, String tags, Instant now) { + UUID articleId = UUID.randomUUID(); + UUID revisionId = UUID.randomUUID(); + jdbc.update("INSERT INTO article(id, slug, title, excerpt, markdown_source, status, published_at, created_at, updated_at, version, category_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)", + articleId, slug, title, excerpt, markdown, status, publishedAt == null ? null : Timestamp.from(publishedAt), + Timestamp.from(now), Timestamp.from(now), categoryId); + if ("PUBLISHED".equals(status)) { + jdbc.update("INSERT INTO article_revision(id, article_id, source_version, title, slug, excerpt, markdown_source, " + + "category_snapshot, tag_snapshot, created_at) VALUES (?, ?, 0, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?)", + revisionId, articleId, title, slug, excerpt, markdown, + categoryId == null ? null : "{\"name\":\"Backend\",\"slug\":\"backend\"}", tags, Timestamp.from(now)); + jdbc.update("UPDATE article SET published_revision_id = ? WHERE id = ?", revisionId, articleId); + } + } +} diff --git a/apps/api/src/test/java/io/haoblog/PublicApiIT.java b/apps/api/src/test/java/io/haoblog/PublicApiIT.java index c252f7f..27e66c9 100644 --- a/apps/api/src/test/java/io/haoblog/PublicApiIT.java +++ b/apps/api/src/test/java/io/haoblog/PublicApiIT.java @@ -212,7 +212,7 @@ WHERE a.status IN ('PUBLISHED', 'SCHEDULED') .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("scheduled-feed")))) .andExpect(header().exists("ETag")) .andReturn(); - assertEquals(6, count(sitemap.getResponse().getContentAsString(), "")); + assertEquals(7, count(sitemap.getResponse().getContentAsString(), "")); mvc.perform(get("/sitemap.xml").header("If-None-Match", sitemap.getResponse().getHeader("ETag"))) .andExpect(status().isNotModified()).andExpect(content().string("")); } diff --git a/apps/api/src/test/java/io/haoblog/PublicApiTest.java b/apps/api/src/test/java/io/haoblog/PublicApiTest.java index a041775..d52fdeb 100644 --- a/apps/api/src/test/java/io/haoblog/PublicApiTest.java +++ b/apps/api/src/test/java/io/haoblog/PublicApiTest.java @@ -27,7 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import com.jayway.jsonpath.JsonPath; -@WebMvcTest({PublicSiteController.class, PublicArticleController.class, PublicFeedController.class}) +@WebMvcTest({PublicSiteController.class, PublicArticleController.class, PublicFeedController.class, io.haoblog.site.web.PublicGardenController.class}) @Import({io.haoblog.shared.web.TraceIdFilter.class, io.haoblog.shared.web.GlobalExceptionHandler.class, io.haoblog.shared.web.ProblemResponseWriter.class}) class PublicApiTest { @@ -35,6 +35,7 @@ class PublicApiTest { @MockitoBean io.haoblog.site.application.SiteService siteService; @MockitoBean io.haoblog.content.application.ArticleService articleService; @MockitoBean io.haoblog.site.application.PublicFeedService publicFeedService; + @MockitoBean io.haoblog.site.application.GardenGraphService gardenGraphService; @BeforeEach void rejectInvalidServiceArguments() { @@ -105,6 +106,24 @@ void rejectInvalidServiceArguments() { } } + @Test void gardenSupportsStableGraphResponseAnd304() throws Exception { + when(gardenGraphService.get()).thenReturn(new io.haoblog.site.application.GardenGraphService.Graph( + List.of(new io.haoblog.site.application.GardenGraphService.Node( + "article:visible", io.haoblog.site.application.GardenGraphService.NodeType.ARTICLE, + "Visible", "/articles/visible", "Excerpt", Instant.parse("2026-01-01T00:00:00Z"), 1)), + List.of(new io.haoblog.site.application.GardenGraphService.Edge( + "article:visible", "tag:java", io.haoblog.site.application.GardenGraphService.EdgeKind.MEMBERSHIP, 1)), false)); + + var first = mvc.perform(get("/api/v1/public/garden")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes[0].href").value("/articles/visible")) + .andExpect(jsonPath("$.edges[0].kind").value("MEMBERSHIP")) + .andExpect(header().string("Cache-Control", "public, max-age=0, s-maxage=60, must-revalidate")) + .andReturn(); + mvc.perform(get("/api/v1/public/garden").header("If-None-Match", first.getResponse().getHeader("ETag"))) + .andExpect(status().isNotModified()).andExpect(content().string("")); + } + @Test void unknownStaticResourcePathReturnsSafeProblem() throws Exception { mvc.perform(get("/missing.css").header("X-Request-ID", "request-static-404")) .andExpect(status().isNotFound()) diff --git a/apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java new file mode 100644 index 0000000..f33b967 --- /dev/null +++ b/apps/api/src/test/java/io/haoblog/site/application/GardenGraphServiceTest.java @@ -0,0 +1,54 @@ +package io.haoblog.site.application; + +import io.haoblog.content.application.ArticleService; +import io.haoblog.toolbox.application.ToolManagementService; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GardenGraphServiceTest { + @Test + void normalizesTagsBuildsWeightedCoOccurrenceAndKeepsToolHrefInternal() { + var article = new ArticleService.GardenArticle(UUID.randomUUID(), "first-post", "First post", "Excerpt", + Instant.parse("2026-01-02T00:00:00Z"), + new ArticleService.GardenTaxonomy("Backend", "backend"), + List.of(new ArticleService.GardenTaxonomy("Java", "Java"), new ArticleService.GardenTaxonomy("Spring", "spring"))); + var tool = new ToolManagementService.GardenTool(UUID.randomUUID(), "JSON Tool", "json-tool", "Format JSON", + "Utilities", "utilities", List.of("java", "spring"), 1); + + var graph = GardenGraphService.build(List.of(article), List.of(tool)); + + assertEquals(6, graph.nodes().size()); + assertTrue(graph.nodes().stream().anyMatch(node -> node.id().equals("tool:json-tool") + && node.href().equals("/tools?tool=json-tool"))); + assertEquals(2, graph.edges().stream() + .filter(edge -> edge.kind() == GardenGraphService.EdgeKind.CO_OCCURRENCE) + .findFirst().orElseThrow().weight()); + assertEquals(graph, GardenGraphService.build(List.of(article), List.of(tool))); + } + + @Test + void deterministicallyClipsNodesAndEdges() { + var articles = java.util.stream.IntStream.range(0, 205).mapToObj(index -> + new ArticleService.GardenArticle(UUID.randomUUID(), "post-" + index, "Post " + index, null, + Instant.parse("2026-01-01T00:00:00Z").plusSeconds(index), null, List.of())).toList(); + var clippedNodes = GardenGraphService.build(articles, List.of()); + assertEquals(200, clippedNodes.nodes().size()); + assertTrue(clippedNodes.truncated()); + assertEquals("article:post-204", clippedNodes.nodes().get(0).id()); + + var sharedTags = java.util.stream.IntStream.range(0, 100).mapToObj(index -> + new ArticleService.GardenArticle(UUID.randomUUID(), "dense-" + index, "Dense " + index, null, + Instant.parse("2026-01-01T00:00:00Z"), null, + java.util.stream.IntStream.range(0, 6) + .mapToObj(tag -> new ArticleService.GardenTaxonomy("Tag " + tag, "tag-" + tag)).toList())).toList(); + var clippedEdges = GardenGraphService.build(sharedTags, List.of()); + assertEquals(600, clippedEdges.edges().size()); + assertTrue(clippedEdges.truncated()); + } +} diff --git a/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java b/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java index e98f62b..afe9c6c 100644 --- a/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java +++ b/apps/api/src/test/java/io/haoblog/site/application/PublicFeedServiceTest.java @@ -55,7 +55,7 @@ void sitemapReadsPublishedArticlesInFiveHundredItemBatches() throws Exception { Document xml = parse(new PublicFeedService(siteService, articleService).sitemap().body()); - assertEquals(506, xml.getElementsByTagNameNS("http://www.sitemaps.org/schemas/sitemap/0.9", "url").getLength()); + assertEquals(507, xml.getElementsByTagNameNS("http://www.sitemaps.org/schemas/sitemap/0.9", "url").getLength()); verify(articleService).listPublishedBatch(eq(0), eq(500)); verify(articleService).listPublishedBatch(eq(1), eq(500)); } 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 29fa11d..11869da 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 @@ -33,4 +33,39 @@ void rejectsNonOriginPublicUrls() { assertThrows(IllegalArgumentException.class, () -> new SiteService(repository, "javascript:alert(1)", "Hao")); assertThrows(IllegalArgumentException.class, () -> new SiteService(repository, "https://user:pass@blog.example.test", "Hao")); } + + @Test + void validatesManifestByProfileAndRejectsEnablingWithoutConfiguration() { + var repository = mock(SiteSettingRepository.class); + var setting = new SiteSetting("default", "HaoBlog", "Night station"); + when(repository.findBySiteKey("default")).thenReturn(Optional.of(setting)); + + assertEquals("http://localhost:8080/music.json", + SiteService.normalizeMusicManifestUrl(" http://localhost:8080/music.json ", "local")); + assertEquals("https://cdn.example.test/music.json", + SiteService.normalizeMusicManifestUrl("https://cdn.example.test/music.json", "prod")); + assertThrows(IllegalArgumentException.class, + () -> SiteService.normalizeMusicManifestUrl("http://cdn.example.test/music.json", "prod")); + + var service = new SiteService(repository, "https://blog.example.test", "Hao", null, "prod"); + var missing = assertThrows(io.haoblog.shared.web.ProblemException.class, + () -> service.updateSettings(0, true, true, false)); + assertEquals("MUSIC_MANIFEST_NOT_CONFIGURED", missing.getCode()); + } + + @Test + void publicMusicFlagSafelyTurnsOffWhenManifestIsMissing() { + var repository = mock(SiteSettingRepository.class); + var setting = new SiteSetting("default", "HaoBlog", "Night station"); + setting.setMusicEnabled(true); + setting.setThreeDEnabled(true); + when(repository.findBySiteKey("default")).thenReturn(Optional.of(setting)); + + var service = new SiteService(repository, "https://blog.example.test", "Hao", null, "prod"); + var result = service.get(); + + assertEquals(false, result.musicEnabled()); + assertEquals(true, result.threeDEnabled()); + assertEquals(null, result.musicManifestUrl()); + } } diff --git a/apps/web/app/components/garden/GardenCanvas.client.vue b/apps/web/app/components/garden/GardenCanvas.client.vue new file mode 100644 index 0000000..4c42d55 --- /dev/null +++ b/apps/web/app/components/garden/GardenCanvas.client.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/apps/web/app/pages/garden.vue b/apps/web/app/pages/garden.vue index a20c63f..3ce4d04 100644 --- a/apps/web/app/pages/garden.vue +++ b/apps/web/app/pages/garden.vue @@ -1,5 +1,128 @@ - + + + diff --git a/apps/web/app/pages/studio/settings.vue b/apps/web/app/pages/studio/settings.vue index c9492ec..b6cdac4 100644 --- a/apps/web/app/pages/studio/settings.vue +++ b/apps/web/app/pages/studio/settings.vue @@ -5,15 +5,24 @@

正在读取站点控制面板…

-

GLOBAL / COMMENT SIGNAL

+

GLOBAL / EXPERIENCE SIGNALS

+ +

关闭后,公共文章与评论表单会在下一次请求中反映状态;公共缓存策略最长 60 秒。

+

音乐开启前必须配置服务端清单地址;音乐与首页 3D 是独立开关。

当前版本:{{ site.version }}

站点开关已写入,公共信号已刷新。

-
@@ -28,16 +37,25 @@ definePageMeta({ layout: 'studio' }) const { getSite, updateSite } = useAdminSite() const site = ref(null) -const draft = ref(true) +const commentsDraft = ref(true) +const musicDraft = ref(false) +const threeDDraft = ref(false) const loading = ref(true) const saving = ref(false) const saved = ref(false) const error = ref('') +const changed = computed(() => Boolean(site.value && ( + commentsDraft.value !== site.value.commentsEnabled + || musicDraft.value !== site.value.musicEnabled + || threeDDraft.value !== site.value.threeDEnabled +))) async function load() { try { site.value = await getSite() - draft.value = site.value.commentsEnabled + commentsDraft.value = site.value.commentsEnabled + musicDraft.value = site.value.musicEnabled + threeDDraft.value = site.value.threeDEnabled } catch (cause) { error.value = cause instanceof Error ? cause.message : '站点设置暂时不可用。' } finally { @@ -46,12 +64,17 @@ async function load() { } async function save() { - if (!site.value || saving.value || draft.value === site.value.commentsEnabled) return + if (!site.value || saving.value || !changed.value) return saving.value = true saved.value = false error.value = '' try { - site.value = await updateSite({ version: site.value.version, commentsEnabled: draft.value }) + site.value = await updateSite({ + version: site.value.version, + commentsEnabled: commentsDraft.value, + musicEnabled: musicDraft.value, + threeDEnabled: threeDDraft.value, + }) saved.value = true } catch (cause) { error.value = cause instanceof Error ? cause.message : '站点开关写入失败。' diff --git a/apps/web/app/pages/tools.vue b/apps/web/app/pages/tools.vue index a75ca4a..c31126d 100644 --- a/apps/web/app/pages/tools.vue +++ b/apps/web/app/pages/tools.vue @@ -19,18 +19,20 @@ const { data, error } = await usePublicApi('/api/v1/public/tools', default: () => emptyTools, }) +const route = useRoute() +const selectedToolSlug = computed(() => typeof route.query.tool === 'string' ? route.query.tool : '') const category = ref('') const type = ref('') const keyword = ref('') const sortMode = ref('stable') const favorites = ref(new Set()) const usage = ref>({}) -const expanded = ref(new Set()) const embeddedWorker = useEmbeddedWorker() provide(embeddedWorkerKey, embeddedWorker) const tools = computed(() => data.value?.items || []) const categories = computed(() => data.value?.categories || []) +const expanded = ref(new Set(tools.value.filter(tool => tool.slug === selectedToolSlug.value).map(tool => tool.id))) const filteredTools = computed(() => { const query = keyword.value.trim().toLocaleLowerCase() const result = tools.value.filter((tool) => { @@ -52,6 +54,18 @@ onMounted(() => { usage.value = readUsage() }) +watch([selectedToolSlug, tools], async () => { + if (!import.meta.client) return + const slug = selectedToolSlug.value + if (!slug) return + const tool = tools.value.find(item => item.slug === slug) + if (!tool) return + expanded.value = new Set([...expanded.value, tool.id]) + await nextTick() + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + document.getElementById(`tool-${tool.slug}`)?.scrollIntoView({ block: 'center', behavior: reducedMotion ? 'auto' : 'smooth' }) +}, { immediate: true }) + function compareTools(left: PublicTool, right: PublicTool, mode: Exclude) { const leftUsage = usage.value[left.id] || { count: 0, lastUsed: '' } const rightUsage = usage.value[right.id] || { count: 0, lastUsed: '' } @@ -155,7 +169,7 @@ function safeExternalUrl(url: string | null) { 尝试清除一个筛选条件。
    -
  1. +
  2. - - 命令入口即将开放 + + 打开安全终端
    AI 入口即将开放
    + diff --git a/apps/web/app/utils/terminalCommands.ts b/apps/web/app/utils/terminalCommands.ts new file mode 100644 index 0000000..d5f9d01 --- /dev/null +++ b/apps/web/app/utils/terminalCommands.ts @@ -0,0 +1,54 @@ +export type TerminalCommand = + | { kind: 'help' } + | { kind: 'list'; target: 'posts' | 'tools' } + | { kind: 'grep'; query: string } + | { kind: 'open'; href: string } + | { kind: 'theme'; theme: 'night' | 'blueprint' } + | { kind: 'clear' } + | { kind: 'ask'; question: string } + +const MAX_COMMAND_LENGTH = 200 +const MAX_ARGUMENT_LENGTH = 100 +const SAFE_SLUG = '[a-z0-9](?:[a-z0-9-]{0,118}[a-z0-9])?' +const FORBIDDEN_INPUT = /[;|&<>`$]|\b(?:javascript|data|file|http|https):|\/\//i + +export function parseTerminalCommand(input: string): TerminalCommand | { kind: 'error'; message: string } { + const command = input.trim() + if (!command) return { kind: 'error', message: '请输入命令。可输入 help 查看白名单。' } + if (Array.from(command).length > MAX_COMMAND_LENGTH) return { kind: 'error', message: `命令不能超过 ${MAX_COMMAND_LENGTH} 个字符。` } + if (FORBIDDEN_INPUT.test(command)) return { kind: 'error', message: '已拒绝:终端不执行脚本、管道、重定向或外部 URL。' } + + if (command === 'help') return { kind: 'help' } + if (command === 'clear') return { kind: 'clear' } + if (command === 'ls posts') return { kind: 'list', target: 'posts' } + if (command === 'ls tools') return { kind: 'list', target: 'tools' } + if (command === 'theme night') return { kind: 'theme', theme: 'night' } + if (command === 'theme blueprint') return { kind: 'theme', theme: 'blueprint' } + + const quoted = command.match(/^(grep|ask) "([^"]*)"$/) + if (quoted) { + const value = quoted[2] + if (!value || Array.from(value).length > MAX_ARGUMENT_LENGTH) { + return { kind: 'error', message: `参数不能为空且不能超过 ${MAX_ARGUMENT_LENGTH} 个字符。` } + } + return quoted[1] === 'grep' ? { kind: 'grep', query: value } : { kind: 'ask', question: value } + } + + const open = command.match(new RegExp(`^open (\\/(?:articles|posts)\\/${SAFE_SLUG})$`)) + if (open) return { kind: 'open', href: open[1]!.replace(/^\/posts\//, '/articles/') } + if (/^open\s/.test(command)) return { kind: 'error', message: '只允许打开 /articles/{slug} 或兼容别名 /posts/{slug}。' } + if (/^(grep|ask)\s/.test(command)) return { kind: 'error', message: '只接受双引号包裹的有限长度参数。' } + if (/^(ls|theme)\s/.test(command)) return { kind: 'error', message: '该命令的参数不在白名单中。' } + return { kind: 'error', message: '未知命令。可输入 help 查看白名单。' } +} + +export const terminalHelp = [ + 'help 显示白名单命令', + 'ls posts 列出最近公开文章(最多 20 条)', + 'ls tools 列出公开工具(最多 20 条)', + 'grep "query" 搜索公开文章(最多 20 条)', + 'open /articles/slug 打开文章;/posts/slug 为兼容别名', + 'theme night|blueprint 切换本地主题', + 'clear 清空当前输出', + 'ask "question" 显示 AI 未开放提示和站内搜索入口', +] diff --git a/apps/web/app/utils/theme.ts b/apps/web/app/utils/theme.ts new file mode 100644 index 0000000..8fb5d60 --- /dev/null +++ b/apps/web/app/utils/theme.ts @@ -0,0 +1,60 @@ +import { onBeforeUnmount, onMounted, readonly, ref } from 'vue' + +export type SiteTheme = 'night' | 'blueprint' + +export const THEME_STORAGE_KEY = 'haoblog-theme' + +export function normalizeTheme(value: unknown): SiteTheme { + return value === 'blueprint' ? 'blueprint' : 'night' +} + +export function useTheme() { + const theme = typeof useState === 'function' + ? useState('haoblog-theme', () => 'night') + : ref('night') + let transitionTimer: number | undefined + + function applyTheme(next: SiteTheme, persist = true) { + theme.value = normalizeTheme(next) + if (!import.meta.client) return + + const root = document.documentElement + root.dataset.theme = theme.value + if (persist) { + try { + localStorage.setItem(THEME_STORAGE_KEY, theme.value) + } catch { + // localStorage 不可用时保留当前会话主题。 + } + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + if (transitionTimer) window.clearTimeout(transitionTimer) + delete root.dataset.themeTransition + return + } + root.dataset.themeTransition = 'on' + if (transitionTimer) window.clearTimeout(transitionTimer) + transitionTimer = window.setTimeout(() => { + delete root.dataset.themeTransition + }, 420) + } + + function syncTheme() { + if (!import.meta.client) return + let stored: string | null = null + try { + stored = localStorage.getItem(THEME_STORAGE_KEY) + } catch { + stored = null + } + applyTheme(normalizeTheme(stored || document.documentElement.dataset.theme), false) + } + + onMounted(syncTheme) + onBeforeUnmount(() => { + if (transitionTimer) window.clearTimeout(transitionTimer) + }) + + return { theme: readonly(theme), setTheme: applyTheme, syncTheme } +} diff --git a/apps/web/e2e/stage3.spec.ts b/apps/web/e2e/stage3.spec.ts index a579436..4424ae0 100644 --- a/apps/web/e2e/stage3.spec.ts +++ b/apps/web/e2e/stage3.spec.ts @@ -49,8 +49,8 @@ test.describe.serial('S3-08 public reading acceptance', () => { const homepage = await page.request.get('/') expect(homepage.status()).toBe(200) const homepageHtml = await homepage.text() - expect(homepageHtml).toContain('OBSERVATION / INITIAL FRAME') - expect(homepageHtml).toContain('

    HaoBlog

    ') + expect(homepageHtml).toContain('FIRST FRAME / 夜空校准 / CALIBRATION') + expect(homepageHtml).toContain('