feat: add svn preset management and optimize docker builds

This commit is contained in:
liumangmang
2026-06-11 13:57:20 +08:00
parent 409c5a81e4
commit b5c7907c23
24 changed files with 1317 additions and 138 deletions
+4
View File
@@ -2,3 +2,7 @@ target/
.git/ .git/
.idea/ .idea/
outputs/ outputs/
**/node_modules/
**/dist/
**/node/
outputs.nobody-backup-*/
+1 -1
View File
@@ -9,7 +9,7 @@
- 核心目录: - 核心目录:
- `src/main/java/com/svnlog/` - `src/main/java/com/svnlog/`
- `docs/` - `docs/`
- SVN 预设地址:`src/main/resources/application.properties``svn.presets[*]` - SVN 预设地址:通过仓库管理页维护,持久化至 `outputs/repository-configs.json`
## 2. 常用命令(Build / Lint / Test / Run ## 2. 常用命令(Build / Lint / Test / Run
以下命令默认在仓库根目录执行。 以下命令默认在仓库根目录执行。
+23 -11
View File
@@ -1,9 +1,11 @@
# ============================================================ # ============================================================
# Docker 镜像仓库加速(默认使用 docker.1ms.run 国内代理) # Docker 镜像仓库加速(默认使用 docker.1ms.run 国内代理)
# 如需切换回 Docker Hub # 如需切换回 Docker Hub
# docker compose build --build-arg REGISTRY_MIRROR=docker.io/library # DOCKER_REGISTRY_MIRROR=docker.io/library make up
# ============================================================ # ============================================================
ARG REGISTRY_MIRROR=docker.1ms.run/library ARG REGISTRY_MIRROR=docker.1ms.run/library
# 内部/开发用途,推荐通过 make fast-up 触发快速构建
ARG FAST_BUILD=false
# ============================================================ # ============================================================
# Stage 1: 前端构建(Vue 3 + Vite # Stage 1: 前端构建(Vue 3 + Vite
@@ -14,7 +16,7 @@ WORKDIR /frontend
COPY frontend-vue/package.json frontend-vue/package-lock.json ./ COPY frontend-vue/package.json frontend-vue/package-lock.json ./
RUN npm ci RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend-vue/ ./ COPY frontend-vue/ ./
@@ -23,20 +25,20 @@ RUN npm run build
# ============================================================ # ============================================================
# Stage 2: 后端构建(Maven + Java 8 # Stage 2: 后端构建(Maven + Java 8
# ============================================================ # ============================================================
FROM ${REGISTRY_MIRROR}/maven:3.9.6-eclipse-temurin-8 AS builder FROM ${REGISTRY_MIRROR}/maven:3.9.6-eclipse-temurin-8 AS builder-base
# Maven JVM 调优:增大堆内存、启用并行 # Maven JVM 调优:增大堆内存、启用并行
ENV MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m -Djava.util.concurrent.ForkJoinPool.common.parallelism=4" ENV MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m -Djava.util.concurrent.ForkJoinPool.common.parallelism=4"
WORKDIR /app WORKDIR /app
# 使用阿里云 Maven 镜像加速依赖下载(替换 Maven Central # 使用阿里云 Maven 镜像加速依赖下载(避免被 /root/.m2 缓存挂载点隐藏
COPY maven-settings.xml /root/.m2/settings.xml COPY maven-settings.xml /app/maven-settings.xml
COPY pom.xml . COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \ RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests -T 1C dependency:go-offline mvn -s /app/maven-settings.xml -B -DskipTests -T 1C dependency:go-offline
COPY src ./src COPY src ./src
@@ -44,11 +46,21 @@ COPY src ./src
# vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend # vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend
COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2 COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2
# 前端产物已由 frontend-builder 阶段构建并 COPY 进来; # 默认构建分支(不缓存 /app/target,执行 clean
# 此阶段不含 frontend-vue/,且离线模式无法下载 Node,必须跳过前端构建。 FROM builder-base AS builder-false
# -T 1C: 按 CPU 核数并行; -o: 离线模式(依赖已缓存,跳过元数据检查)
RUN --mount=type=cache,target=/root/.m2 \ RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests -T 1C -o clean package -Dskip.frontend.build=true mvn -s /app/maven-settings.xml -B -DskipTests -T 1C clean package -Dskip.frontend.build=true && \
cp /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar /app/svn-log-tool-1.0.0-jar-with-dependencies.jar
# 快速开发迭代构建分支(缓存 /app/target,不执行 clean
FROM builder-base AS builder-true
RUN --mount=type=cache,target=/root/.m2 \
--mount=type=cache,target=/app/target \
mvn -s /app/maven-settings.xml -B -DskipTests -T 1C package -Dskip.frontend.build=true && \
cp /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar /app/svn-log-tool-1.0.0-jar-with-dependencies.jar
# 根据 FAST_BUILD 的值决定最终作为 builder 的阶段
FROM builder-${FAST_BUILD} AS builder
# ============================================================ # ============================================================
# Stage 3: 运行镜像(最小化 JRE) # Stage 3: 运行镜像(最小化 JRE)
@@ -56,7 +68,7 @@ RUN --mount=type=cache,target=/root/.m2 \
FROM ${REGISTRY_MIRROR}/eclipse-temurin:8-jre-alpine FROM ${REGISTRY_MIRROR}/eclipse-temurin:8-jre-alpine
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar COPY --from=builder /app/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar
EXPOSE 18088 EXPOSE 18088
+9 -2
View File
@@ -5,14 +5,14 @@
# ============================================================ # ============================================================
DOCKER_REGISTRY_MIRROR ?= docker.1ms.run/library DOCKER_REGISTRY_MIRROR ?= docker.1ms.run/library
.PHONY: up down status .PHONY: up down status fast-up
COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi) COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1
up: up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi @if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@REGISTRY_MIRROR=$(DOCKER_REGISTRY_MIRROR) $(BUILD_ENV) $(COMPOSE_CMD) up -d --build @DOCKER_REGISTRY_MIRROR=$(DOCKER_REGISTRY_MIRROR) FAST_BUILD=false $(BUILD_ENV) $(COMPOSE_CMD) up -d --build
@echo "Application is starting at http://localhost:18088" @echo "Application is starting at http://localhost:18088"
down: down:
@@ -23,3 +23,10 @@ status:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi @if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(BUILD_ENV) $(COMPOSE_CMD) ps @$(BUILD_ENV) $(COMPOSE_CMD) ps
@echo "Access URL: http://localhost:18088" @echo "Access URL: http://localhost:18088"
fast-up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@echo "WARNING: fast-up is for Java incremental dev only."
@echo "WARNING: Use 'make up' if you changed frontend resources, or deleted/renamed Java files."
@DOCKER_REGISTRY_MIRROR=$(DOCKER_REGISTRY_MIRROR) FAST_BUILD=true $(BUILD_ENV) $(COMPOSE_CMD) up -d --build
@echo "Application is starting at http://localhost:18088"
+4 -1
View File
@@ -9,9 +9,12 @@ SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口
## 常用命令 ## 常用命令
```bash ```bash
# 一键启动(Docker,每次会重新构建镜像并打包最新代码) # 一键启动(Docker,每次会重新构建镜像并打包最新代码,安全默认构建
make up make up
# 快速开发迭代启动(Docker,保留 Java 编译缓存,仅适合本地 Java 增量开发)
make fast-up
# 查看状态 # 查看状态
make status make status
+4 -3
View File
@@ -4,9 +4,10 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
# Docker 镜像加速(默认 Docker Hub,国内可阿里云) # Docker 镜像加速(使用 DOCKER_REGISTRY_MIRROR 传递,国内可阿里云,例如 registry.cn-hangzhou.aliyuncs.com/library,不能带 https://
# 使用方式: REGISTRY_MIRROR=registry.cn-hangzhou.aliyuncs.com/library docker compose build # 内部/开发用途,推荐通过 make fast-up 使用快速构建
REGISTRY_MIRROR: ${REGISTRY_MIRROR:-docker.1ms.run/library} REGISTRY_MIRROR: ${DOCKER_REGISTRY_MIRROR:-docker.1ms.run/library}
FAST_BUILD: ${FAST_BUILD:-false}
container_name: svn-log-tool container_name: svn-log-tool
network_mode: host network_mode: host
volumes: volumes:
+16 -8
View File
@@ -34,12 +34,19 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
http://localhost:18088 http://localhost:18088
``` ```
## Docker 构建行为 ## Docker 构建行为与优化
- `make up` 保持“重新构建并启动”的语义,每次都会执行一次 Maven 打包,确保容器内是最新代码。 - **Docker 镜像加速**:默认使用 `docker.1ms.run/library` 代理。如果需要切换回 Docker Hub 或使用国内其他镜像(如阿里云镜像,例如 `registry.cn-hangzhou.aliyuncs.com/library`,注意不能带 `https://`),可以通过命令行传递 `DOCKER_REGISTRY_MIRROR` 变量:
- Docker 构建使用 BuildKit 缓存 Maven 本地仓库;首次构建会下载依赖,后续在 `pom.xml` 未变更时会优先命中缓存,不会在每次构建时重复下载全部依赖。 ```bash
- 如果修改了 `pom.xml`、执行了 `docker builder prune`、或切换到新的 Docker 环境,依赖缓存会失效并重新下载。 DOCKER_REGISTRY_MIRROR=registry.cn-hangzhou.aliyuncs.com/library make up
- 如果本机 Docker 未启用 BuildKit,可显式设置 `DOCKER_BUILDKIT=1``COMPOSE_DOCKER_CLI_BUILD=1` 后再执行 `make up` ```
- **快速开发迭代(可选 Fast Build)**:默认情况下,构建仍会执行 `mvn clean package` 以确保打包的正确性(防旧 class 或资源残留)。如果在本地频繁修改 Java 代码,可以使用 `fast-up` 实现秒级编译与构建重启。**注意:如果修改了前端静态资源,或删除/重命名了 Java 类与资源文件,应使用默认构建(`make up`),不要用 Fast Build。**
```bash
make fast-up
```
该模式下,Docker 会使用 BuildKit 挂载缓存 `/app/target` 目录,并不再执行 `clean` 目标,使 Maven 能进行增量编译。
- **依赖缓存**Docker 构建使用 BuildKit 缓存了 Maven 本地仓库(`.m2`)与 npm 缓存(`.npm`)。在依赖文件未变更时,不会重复下载依赖包。
- 如果本机 Docker 未启用 BuildKit,可显式设置 `DOCKER_BUILDKIT=1` 和 `COMPOSE_DOCKER_CLI_BUILD=1` 后再执行构建。
## 页面说明 ## 页面说明
@@ -78,8 +85,9 @@ http://localhost:18088
## SVN 凭据读取优先级 ## SVN 凭据读取优先级
1. 单次请求显式传入的 `username/password`(兼容旧接口) 1. 单次请求显式传入的 `username/password`(兼容旧接口)
2. 设置页保存的运行时 `svnUsername/svnPassword` 2. 预设凭据
3. 环境变量 `SVN_USERNAME` / `SVN_PASSWORD` 3. 全局设置保存的运行时 `svnUsername/svnPassword`
4. 环境变量 `SVN_USERNAME` / `SVN_PASSWORD`
`GET /api/settings` 不会回显 `openaiApiKey` 或 `svnPassword` 明文,前端通过 `openaiApiKeyConfigured` 和 `svnCredentialsConfigured` 展示配置状态。 `GET /api/settings` 不会回显 `openaiApiKey` 或 `svnPassword` 明文,前端通过 `openaiApiKeyConfigured` 和 `svnCredentialsConfigured` 展示配置状态。
@@ -105,7 +113,7 @@ http://localhost:18088
## SVN 预设来源与调用方式 ## SVN 预设来源与调用方式
- SVN 地址统一维护在 `application.properties``svn.presets[*]` 中。 - SVN 地址统一通过仓库管理页维护,并持久化到 `outputs/repository-configs.json` 文件中。
- 前端不再传 SVN URL,业务接口统一传 `presetId`,后端按 `presetId` 解析地址。 - 前端不再传 SVN URL,业务接口统一传 `presetId`,后端按 `presetId` 解析地址。
- `GET /api/svn/presets` 仅返回 `id` 与 `name`(不返回 `url`)。 - `GET /api/svn/presets` 仅返回 `id` 与 `name`(不返回 `url`)。
+5 -1
View File
@@ -14,7 +14,11 @@
</router-link> </router-link>
<router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="SVN 日志抓取"> <router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="SVN 日志抓取">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>SVN 日志抓取</span> <span>日志抓取</span>
</router-link>
<router-link class="sidebar-link" to="/presets" active-class="active" aria-label="仓库管理">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>仓库管理</span>
</router-link> </router-link>
<router-link class="sidebar-link" to="/history" active-class="active" aria-label="任务历史"> <router-link class="sidebar-link" to="/history" active-class="active" aria-label="任务历史">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
+3 -1
View File
@@ -5,13 +5,15 @@ import DashboardView from './views/DashboardView.vue'
import SvnFetchView from './views/SvnFetchView.vue' import SvnFetchView from './views/SvnFetchView.vue'
import HistoryView from './views/HistoryView.vue' import HistoryView from './views/HistoryView.vue'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import SvnPresetsView from './views/SvnPresetsView.vue'
import './styles/main.css' import './styles/main.css'
const routes = [ const routes = [
{ path: '/', redirect: '/dashboard' }, { path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'dashboard', component: DashboardView, meta: { title: '工作台', desc: '查看系统状态与最近产物' } }, { path: '/dashboard', name: 'dashboard', component: DashboardView, meta: { title: '工作台', desc: '查看系统状态与最近产物' } },
{ path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: 'SVN 日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } }, { path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: '日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } },
{ path: '/history', name: 'history', component: HistoryView, meta: { title: '任务历史', desc: '查看任务执行状态、日志与产物' } }, { path: '/history', name: 'history', component: HistoryView, meta: { title: '任务历史', desc: '查看任务执行状态、日志与产物' } },
{ path: '/presets', name: 'presets', component: SvnPresetsView, meta: { title: '仓库管理', desc: '管理 SVN 仓库预设' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } }, { path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } },
] ]
+15 -3
View File
@@ -1,12 +1,22 @@
<template> <template>
<div class="svn-fetch"> <div class="svn-fetch">
<div class="card"> <!-- 空状态无预设时引导去仓库管理新增 -->
<div v-if="!loading && !presets.length" class="card" style="text-align:center;padding:var(--space-2xl) var(--space-lg);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--c-text-muted);margin-bottom:var(--space-md);" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">尚未配置 SVN 仓库</h3>
<p style="font-size:13px;color:var(--c-text-muted);margin-bottom:var(--space-lg);">
请先在<a href="#/presets" style="color:var(--c-primary);font-weight:500;">仓库管理</a>页面新增仓库后再来抓取日志
</p>
<router-link to="/presets" class="btn btn-primary">前往仓库管理</router-link>
</div>
<div v-else class="card">
<div class="card-title"> <div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
SVN 批量抓取参数 SVN 批量抓取参数
</div> </div>
<div class="alert alert-info" style="margin-bottom:var(--space-md);"> <div class="alert alert-info" style="margin-bottom:var(--space-md);">
默认已填充 3 个常用项目路径可选月份自动填充版本号或手动填写 支持多个项目批量抓取选月份自动填充版本号或手动填写
</div> </div>
<div style="padding:var(--space-md);background:rgba(15,23,42,0.4);border-radius:var(--radius-md);margin-bottom:var(--space-lg);border:1px solid var(--c-border-light);"> <div style="padding:var(--space-md);background:rgba(15,23,42,0.4);border-radius:var(--radius-md);margin-bottom:var(--space-lg);border:1px solid var(--c-border-light);">
@@ -114,6 +124,7 @@ const { toast } = useToast()
const presets = ref([]) const presets = ref([])
const defaultPresetId = ref('') const defaultPresetId = ref('')
const loading = ref(true)
const testing = ref(false) const testing = ref(false)
const running = ref(false) const running = ref(false)
const autoFillLoading = ref(false) const autoFillLoading = ref(false)
@@ -166,7 +177,8 @@ onMounted(async () => {
startRevision: '', startRevision: '',
endRevision: '', endRevision: '',
})) }))
} catch (err) { toast(err.message, true) } loading.value = false
} catch (err) { loading.value = false; toast(err.message, true) }
const now = new Date() const now = new Date()
const y = now.getFullYear() const y = now.getFullYear()
+335
View File
@@ -0,0 +1,335 @@
<template>
<div class="presets-manage">
<!-- 空状态无预设时显示引导 -->
<div v-if="!loading && !items.length" class="card" style="text-align:center;padding:var(--space-2xl) var(--space-lg);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--c-text-muted);margin-bottom:var(--space-md);" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">暂无 SVN 仓库</h3>
<p style="font-size:13px;color:var(--c-text-muted);margin-bottom:var(--space-lg);max-width:400px;margin-left:auto;margin-right:auto;">
请新增一个仓库填写 SVN 地址和可选的账号密码以开始使用
</p>
<button type="button" class="btn btn-primary" @click="showAddForm = true">新增仓库</button>
</div>
<!-- 顶部操作栏有预设时显示 -->
<div v-if="items.length" class="toolbar" style="justify-content:space-between;">
<span style="font-size:13px;color:var(--c-text-secondary);"> {{ items.length }} 个仓库</span>
<button type="button" class="btn btn-primary btn-sm" @click="showAddForm = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
新增仓库
</button>
</div>
<!-- 新增表单弹窗 -->
<div v-if="showAddForm" class="modal-overlay" @click.self="cancelAdd">
<div class="modal" role="dialog" aria-label="新增 SVN 仓库">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">新增 SVN 仓库</h3>
<form @submit.prevent="onAdd">
<div class="form-grid">
<div class="form-group span-all">
<label for="add-name">名称</label>
<input id="add-name" class="form-input" v-model="addForm.name" placeholder="例如 PRS-7050场站智慧管控" required autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="add-url">SVN 地址</label>
<input id="add-url" class="form-input" v-model="addForm.url" placeholder="https://svn.example.com/svn/project" required autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="add-username">SVN 用户名可选</label>
<input id="add-username" class="form-input" v-model="addForm.svnUsername" placeholder="预设专用" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="add-password">SVN 密码可选</label>
<input id="add-password" class="form-input" type="password" v-model="addForm.svnPassword" placeholder="预设专用" autocomplete="new-password" />
</div>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="adding">
<span v-if="adding" class="spinner"></span>
创建
</button>
<button type="button" class="btn" @click="cancelAdd">取消</button>
</div>
</form>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
<div class="modal" role="dialog" aria-label="编辑 SVN 仓库">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">编辑仓库</h3>
<form @submit.prevent="onEdit">
<div class="form-grid">
<div class="form-group span-all">
<label for="edit-name">名称</label>
<input id="edit-name" class="form-input" v-model="editForm.name" required autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="edit-url">SVN 地址</label>
<input id="edit-url" class="form-input" v-model="editForm.url" required autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="edit-username">SVN 用户名</label>
<input id="edit-username" class="form-input" v-model="editForm.svnUsername" placeholder="留空不覆盖" autocomplete="off" spellcheck="false" />
<span v-if="editTarget.svnCredentialsConfigured" style="font-size:11px;color:var(--c-warning);">已有凭据留空则保留原值</span>
</div>
<div class="form-group">
<label for="edit-password">SVN 密码</label>
<input id="edit-password" class="form-input" type="password" v-model="editForm.svnPassword" placeholder="留空不覆盖" autocomplete="new-password" />
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.clearSvnPassword" />
清空已保存的 SVN 密码
</label>
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.enabled" />
启用
</label>
</div>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner"></span>
保存
</button>
<button type="button" class="btn" @click="editTarget = null">取消</button>
</div>
</form>
</div>
</div>
<!-- 删除确认弹窗 -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
<div class="modal" style="max-width:400px;" role="dialog" aria-label="确认删除">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">确认删除</h3>
<p style="font-size:13px;color:var(--c-text-secondary);margin-bottom:var(--space-lg);">
确定要将仓库 <strong style="color:var(--c-text);">{{ deleteTarget.name }}</strong> 停用吗可随时在编辑中重新启用
</p>
<div class="btn-group">
<button type="button" class="btn btn-danger" :disabled="deleting" @click="onDelete">
<span v-if="deleting" class="spinner"></span>
停用仓库
</button>
<button type="button" class="btn" @click="deleteTarget = null">取消</button>
</div>
</div>
</div>
<!-- 表格列表 -->
<div v-if="items.length" class="table-wrap" style="margin-top:var(--space-md);">
<table>
<thead>
<tr>
<th>名称</th>
<th>SVN 地址</th>
<th>SVN 账号</th>
<th>凭据</th>
<th>最后使用</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id" :style="{ opacity: item.enabled ? 1 : 0.5 }">
<td><strong>{{ item.name }}</strong></td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:12px;" :title="item.url">{{ item.url }}</td>
<td style="font-family:var(--font-mono);font-size:12px;">{{ item.svnUsername || '—' }}</td>
<td>
<span :class="['tag', item.svnCredentialsConfigured ? 'tag-success' : 'tag-muted']">{{ item.svnCredentialsConfigured ? '已配置' : '未配置' }}</span>
</td>
<td style="font-size:12px;color:var(--c-text-muted);font-family:var(--font-mono);">{{ formatTime(item.lastUsedAt) }}</td>
<td>
<span :class="['tag', item.enabled ? 'tag-success' : 'tag-muted']">{{ item.enabled ? '启用' : '停用' }}</span>
</td>
<td>
<div class="btn-group" style="gap:4px;">
<button type="button" class="btn btn-sm" @click="startEdit(item)" title="编辑">编辑</button>
<button type="button" class="btn btn-sm" :disabled="testingId === item.id" @click="onTest(item)" title="测试连接">
<span v-if="testingId === item.id" class="spinner"></span>
测试
</button>
<button type="button" class="btn btn-sm btn-danger" @click="deleteTarget = item" title="停用">停用</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 加载态 -->
<div v-if="loading" class="card" style="text-align:center;padding:var(--space-2xl);">
<span class="spinner"></span>
<p style="margin-top:var(--space-sm);font-size:13px;color:var(--c-text-muted);">加载中...</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch } = useApi()
const { toast } = useToast()
const items = ref([])
const loading = ref(true)
const adding = ref(false)
const saving = ref(false)
const deleting = ref(false)
const testingId = ref(null)
const showAddForm = ref(false)
const addForm = ref({ name: '', url: '', svnUsername: '', svnPassword: '' })
const editTarget = ref(null)
const editForm = ref({ name: '', url: '', svnUsername: '', svnPassword: '', clearSvnPassword: false, enabled: true })
const deleteTarget = ref(null)
onMounted(async () => {
await loadItems()
})
async function loadItems() {
loading.value = true
try {
const data = await apiFetch('/api/svn/presets/manage')
items.value = data || []
} catch (err) {
toast(err.message, true)
} finally {
loading.value = false
}
}
function cancelAdd() {
showAddForm.value = false
addForm.value = { name: '', url: '', svnUsername: '', svnPassword: '' }
}
async function onAdd() {
if (!addForm.value.name.trim() || !addForm.value.url.trim()) {
toast('名称和地址为必填项', true)
return
}
adding.value = true
try {
await apiFetch('/api/svn/presets', {
method: 'POST',
body: JSON.stringify(addForm.value),
})
toast('仓库创建成功')
showAddForm.value = false
addForm.value = { name: '', url: '', svnUsername: '', svnPassword: '' }
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
adding.value = false
}
}
function startEdit(item) {
editTarget.value = item
editForm.value = {
name: item.name,
url: item.url,
svnUsername: '',
svnPassword: '',
clearSvnPassword: false,
enabled: item.enabled,
}
}
async function onEdit() {
if (!editTarget.value) return
saving.value = true
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(editTarget.value.id)}`, {
method: 'PUT',
body: JSON.stringify(editForm.value),
})
toast('保存成功')
editTarget.value = null
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
saving.value = false
}
}
async function onTest(item) {
testingId.value = item.id
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(item.id)}/test`, { method: 'POST' })
toast(`连接成功: ${item.name}`)
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
testingId.value = null
}
}
async function onDelete() {
if (!deleteTarget.value) return
deleting.value = true
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(deleteTarget.value.id)}`, { method: 'DELETE' })
toast(`已停用: ${deleteTarget.value.name}`)
deleteTarget.value = null
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
deleting.value = false
}
}
function formatTime(ts) {
if (!ts) return '从未使用'
const d = new Date(ts)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
}
.modal {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 100%;
max-width: 520px;
box-shadow: var(--shadow-elevated);
max-height: 80vh;
overflow-y: auto;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--c-text-secondary);
cursor: pointer;
}
.checkbox-label input {
width: 16px;
height: 16px;
accent-color: var(--c-primary);
}
</style>
@@ -15,10 +15,12 @@ import javax.validation.Valid;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -33,8 +35,11 @@ import com.svnlog.web.dto.AiAnalyzeRequest;
import com.svnlog.web.dto.SettingsUpdateRequest; import com.svnlog.web.dto.SettingsUpdateRequest;
import com.svnlog.web.dto.SvnConnectionRequest; import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest; import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.dto.SvnPresetCreateRequest;
import com.svnlog.web.dto.SvnPresetUpdateRequest;
import com.svnlog.web.dto.SvnVersionRangeRequest; import com.svnlog.web.dto.SvnVersionRangeRequest;
import com.svnlog.web.model.SvnPreset; import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.SvnPresetManageItem;
import com.svnlog.web.model.SvnPresetSummary; import com.svnlog.web.model.SvnPresetSummary;
import com.svnlog.web.model.TaskInfo; import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult; import com.svnlog.web.model.TaskPageResult;
@@ -100,7 +105,7 @@ public class AppController {
@PostMapping("/svn/version-range") @PostMapping("/svn/version-range")
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception { public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
final String traceId = safe(request.getClientTraceId()); final String traceId = safe(request.getClientTraceId());
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getEnabledById(request.getPresetId());
final String url = preset.getUrl(); final String url = preset.getUrl();
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(), request.getUsername(),
@@ -168,6 +173,56 @@ public class AppController {
return response; return response;
} }
/** 返回 SVN 预设管理列表(含已禁用) */
@GetMapping("/svn/presets/manage")
public List<SvnPresetManageItem> listPresetsManage() {
return svnPresetService.listManage();
}
/** 新增 SVN 预设 */
@PostMapping("/svn/presets")
public ResponseEntity<SvnPresetManageItem> createPreset(@Valid @RequestBody SvnPresetCreateRequest request) {
final SvnPresetManageItem item = svnPresetService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(item);
}
/** 编辑 SVN 预设 */
@PutMapping("/svn/presets/{id}")
public SvnPresetManageItem updatePreset(@PathVariable("id") String id,
@Valid @RequestBody SvnPresetUpdateRequest request) {
return svnPresetService.update(id, request);
}
/** 删除(软删除)SVN 预设 */
@DeleteMapping("/svn/presets/{id}")
public Map<String, Object> deletePreset(@PathVariable("id") String id) {
svnPresetService.delete(id);
final Map<String, Object> response = new HashMap<String, Object>();
response.put("id", id);
response.put("success", true);
return response;
}
/** 测试指定预设的 SVN 连接 */
@PostMapping("/svn/presets/{id}/test")
public Map<String, Object> testPresetConnection(@PathVariable("id") String id) throws Exception {
final SvnPreset preset = svnPresetService.getEnabledById(id);
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(null, null, id);
final com.svnlog.core.svn.SVNLogFetcher fetcher = new com.svnlog.core.svn.SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
fetcher.testConnection();
svnPresetService.touchLastUsedAt(id);
final Map<String, Object> response = new HashMap<String, Object>();
response.put("success", true);
response.put("message", "SVN 连接成功");
return response;
}
@PostMapping("/ai/analyze") @PostMapping("/ai/analyze")
public Map<String, String> analyzeLogs(@Valid @RequestBody AiAnalyzeRequest request) { public Map<String, String> analyzeLogs(@Valid @RequestBody AiAnalyzeRequest request) {
final String taskId = taskService.submit("AI_ANALYZE", context -> aiWorkflowService.analyzeAndExport(request, context)); final String taskId = taskService.submit("AI_ANALYZE", context -> aiWorkflowService.analyzeAndExport(request, context));
@@ -304,6 +359,14 @@ public class AppController {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private MediaType resolveMediaType(Path file) throws IOException { private MediaType resolveMediaType(Path file) throws IOException {
final String fileName = file.getFileName().toString().toLowerCase(); final String fileName = file.getFileName().toString().toLowerCase();
if (fileName.endsWith(".md")) { if (fileName.endsWith(".md")) {
@@ -0,0 +1,52 @@
package com.svnlog.web.dto;
import javax.validation.constraints.NotBlank;
public class SvnPresetCreateRequest {
@NotBlank(message = "名称为必填项")
private String name;
@NotBlank(message = "SVN URL 为必填项")
private String url;
private String svnUsername;
private String svnPassword;
private Boolean enabled;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getSvnUsername() {
return svnUsername;
}
public void setSvnUsername(String svnUsername) {
this.svnUsername = svnUsername;
}
public String getSvnPassword() {
return svnPassword;
}
public void setSvnPassword(String svnPassword) {
this.svnPassword = svnPassword;
}
public boolean isEnabled() {
return enabled == null || enabled.booleanValue();
}
}
@@ -0,0 +1,59 @@
package com.svnlog.web.dto;
public class SvnPresetUpdateRequest {
private String name;
private String url;
private String svnUsername;
private String svnPassword;
private Boolean enabled;
private Boolean clearSvnPassword;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getSvnUsername() {
return svnUsername;
}
public void setSvnUsername(String svnUsername) {
this.svnUsername = svnUsername;
}
public String getSvnPassword() {
return svnPassword;
}
public void setSvnPassword(String svnPassword) {
this.svnPassword = svnPassword;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public boolean isClearSvnPassword() {
return clearSvnPassword != null && clearSvnPassword.booleanValue();
}
public void setClearSvnPassword(Boolean clearSvnPassword) {
this.clearSvnPassword = clearSvnPassword;
}
}
@@ -0,0 +1,93 @@
package com.svnlog.web.model;
public class SvnPresetManageItem {
private String id;
private String name;
private String url;
private String svnUsername;
private boolean svnCredentialsConfigured;
private boolean enabled;
private long createdAt;
private long lastUsedAt;
public SvnPresetManageItem() {
}
public SvnPresetManageItem(String id, String name, String url,
String svnUsername, boolean svnCredentialsConfigured,
boolean enabled, long createdAt, long lastUsedAt) {
this.id = id;
this.name = name;
this.url = url;
this.svnUsername = svnUsername;
this.svnCredentialsConfigured = svnCredentialsConfigured;
this.enabled = enabled;
this.createdAt = createdAt;
this.lastUsedAt = lastUsedAt;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getSvnUsername() {
return svnUsername;
}
public void setSvnUsername(String svnUsername) {
this.svnUsername = svnUsername;
}
public boolean isSvnCredentialsConfigured() {
return svnCredentialsConfigured;
}
public void setSvnCredentialsConfigured(boolean svnCredentialsConfigured) {
this.svnCredentialsConfigured = svnCredentialsConfigured;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(long createdAt) {
this.createdAt = createdAt;
}
public long getLastUsedAt() {
return lastUsedAt;
}
public void setLastUsedAt(long lastUsedAt) {
this.lastUsedAt = lastUsedAt;
}
}
@@ -6,6 +6,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.List; import java.util.List;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
@@ -82,58 +83,118 @@ public class RepositoryConfigService {
return CryptoUtils.decrypt(config.getSvnPasswordEncrypted()); return CryptoUtils.decrypt(config.getSvnPasswordEncrypted());
} }
public synchronized boolean updatePresetUrl(String presetId, String newUrl, String name) { /**
if (!containsId(presetId)) { * 创建新的仓库配置
return false; * @param config 已填充的配置对象(id 已生成)
} */
final RepositoryConfig existing = getById(presetId); public synchronized void create(RepositoryConfig config) {
if (newUrl.equals(existing.getSvnUrl())) { config.setCreatedAt(System.currentTimeMillis());
return false; final List<RepositoryConfig> newList = new ArrayList<RepositoryConfig>(configs);
} newList.add(config);
final String oldUrl = existing.getSvnUrl(); persistAndSwap(newList);
existing.setSvnUrl(newUrl); LOGGER.info("新建仓库配置: id={} name={}", config.getId(), config.getName());
existing.setName(name);
save();
LOGGER.info("更新 SVN 预设 URL: id={} name={} oldUrl={} newUrl={}", presetId, name, oldUrl, newUrl);
return true;
} }
public synchronized void migratePreset(String presetId, String name, String url) { /**
if (containsId(presetId)) { * 更新已有仓库配置的字段。密码为空表示不覆盖。
// 已存在,检查 URL 是否有变化,有则更新 */
final RepositoryConfig existing = getById(presetId); public synchronized void update(String id, String name, String url,
if (!url.equals(existing.getSvnUrl())) { String svnUsername, String svnPasswordEncrypted,
final String oldUrl = existing.getSvnUrl(); boolean clearSvnPassword, Boolean enabled) {
existing.setSvnUrl(url); final RepositoryConfig existing = getById(id);
existing.setName(name); // deep-copy: serialize then deserialize so we never mutate the shared object before write
save(); final RepositoryConfig copy = gson.fromJson(gson.toJson(existing), RepositoryConfig.class);
LOGGER.info("更新 SVN 预设 URL: id={} name={} oldUrl={} newUrl={}", presetId, name, oldUrl, url); if (name != null && !name.trim().isEmpty()) {
} copy.setName(name.trim());
return;
} }
final RepositoryConfig config = new RepositoryConfig(); if (url != null && !url.trim().isEmpty()) {
config.setId(presetId); copy.setSvnUrl(url.trim());
config.setName(name); }
config.setType("SVN"); if (svnUsername != null && !isBlank(svnUsername)) {
config.setEnabled(true); copy.setSvnUsername(svnUsername.trim());
config.setSvnUrl(url); }
config.setCreatedAt(System.currentTimeMillis()); if (clearSvnPassword) {
configs.add(config); copy.setSvnPasswordEncrypted("");
save(); } else if (svnPasswordEncrypted != null && !svnPasswordEncrypted.isEmpty()) {
LOGGER.info("迁移 SVN 预设到仓库配置: id={} name={}", presetId, name); copy.setSvnPasswordEncrypted(svnPasswordEncrypted);
}
if (enabled != null) {
copy.setEnabled(enabled.booleanValue());
}
final List<RepositoryConfig> newList = replaceInList(configs, id, copy);
persistAndSwap(newList);
LOGGER.info("更新仓库配置: id={} name={}", id, name);
}
/**
* 软删除:设置 enabled=false
*/
public synchronized void disable(String id) {
final RepositoryConfig existing = getById(id);
final RepositoryConfig copy = gson.fromJson(gson.toJson(existing), RepositoryConfig.class);
copy.setEnabled(false);
final List<RepositoryConfig> newList = replaceInList(configs, id, copy);
persistAndSwap(newList);
LOGGER.info("软删除仓库配置: id={} name={}", id, existing.getName());
}
/**
* 返回所有仓库配置(含已禁用),按 createdAt 降序
*/
public synchronized List<RepositoryConfig> listManage() {
final List<RepositoryConfig> result = new ArrayList<RepositoryConfig>(configs);
Collections.sort(result, new Comparator<RepositoryConfig>() {
@Override
public int compare(RepositoryConfig a, RepositoryConfig b) {
return Long.compare(b.getCreatedAt(), a.getCreatedAt());
}
});
return result;
}
/**
* 更新最后使用时间戳
*/
public synchronized void updateLastUsedAt(String id) {
final RepositoryConfig existing = getById(id);
final RepositoryConfig copy = gson.fromJson(gson.toJson(existing), RepositoryConfig.class);
copy.setLastUsedAt(System.currentTimeMillis());
final List<RepositoryConfig> newList = replaceInList(configs, id, copy);
persistAndSwap(newList);
} }
public synchronized void reload() { public synchronized void reload() {
load(); load();
} }
private synchronized void save() { /**
* 在列表中找到指定 id 的元素并替换为新对象,返回新列表(不修改入参列表)
*/
private static List<RepositoryConfig> replaceInList(List<RepositoryConfig> source, String id, RepositoryConfig replacement) {
final List<RepositoryConfig> newList = new ArrayList<RepositoryConfig>(source.size());
for (RepositoryConfig cfg : source) {
if (id != null && id.equals(cfg.getId())) {
newList.add(replacement);
} else {
newList.add(cfg);
}
}
return newList;
}
/**
* 序列化新列表写盘,成功后才替换内存列表(copy-on-write)。
* 写盘失败时内存状态不变,不会出现"保存报错但内存已改"的问题。
*/
private void persistAndSwap(List<RepositoryConfig> newList) {
try { try {
final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME); final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME);
Files.createDirectories(configFile.getParent()); Files.createDirectories(configFile.getParent());
Files.write(configFile, gson.toJson(configs).getBytes(StandardCharsets.UTF_8)); Files.write(configFile, gson.toJson(newList).getBytes(StandardCharsets.UTF_8));
configs.clear();
configs.addAll(newList);
} catch (IOException e) { } catch (IOException e) {
LOGGER.warn("保存仓库配置失败", e); throw new IllegalStateException("保存仓库配置失败: " + e.getMessage(), e);
} }
} }
@@ -152,7 +152,7 @@ public class SettingsService {
if (outputDir != null && !outputDir.trim().isEmpty()) { if (outputDir != null && !outputDir.trim().isEmpty()) {
outputFileService.setOutputRoot(outputDir); outputFileService.setOutputRoot(outputDir);
} }
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) { if (svnPresetService.containsEnabledPresetId(newDefaultSvnPresetId)) {
this.defaultSvnPresetId = newDefaultSvnPresetId; this.defaultSvnPresetId = newDefaultSvnPresetId;
} }
@@ -282,7 +282,7 @@ public class SettingsService {
if (!isBlank(persistedSettings.getOutputDir())) { if (!isBlank(persistedSettings.getOutputDir())) {
outputFileService.setOutputRoot(persistedSettings.getOutputDir()); outputFileService.setOutputRoot(persistedSettings.getOutputDir());
} }
if (svnPresetService.containsPresetId(persistedSettings.getDefaultSvnPresetId())) { if (svnPresetService.containsEnabledPresetId(persistedSettings.getDefaultSvnPresetId())) {
this.defaultSvnPresetId = persistedSettings.getDefaultSvnPresetId().trim(); this.defaultSvnPresetId = persistedSettings.getDefaultSvnPresetId().trim();
} }
} }
@@ -344,7 +344,7 @@ public class SettingsService {
} }
public String getDefaultSvnPresetId() { public String getDefaultSvnPresetId() {
if (svnPresetService.containsPresetId(defaultSvnPresetId)) { if (svnPresetService.containsEnabledPresetId(defaultSvnPresetId)) {
return defaultSvnPresetId; return defaultSvnPresetId;
} }
return svnPresetService.firstPresetId(); return svnPresetService.firstPresetId();
@@ -376,8 +376,10 @@ public class SettingsService {
} }
public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword, String presetId) { public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword, String presetId) {
String username = resolveSvnUsername(requestUsername); // 优先级:请求参数 > 预设级凭据 > 全局设置 > 环境变量
String password = resolveSvnPassword(requestPassword); String username = resolveRequestUsername(requestUsername);
String password = resolveRequestPassword(requestPassword);
if (isBlank(username) || isBlank(password)) { if (isBlank(username) || isBlank(password)) {
final SvnCredentials presetCredentials = resolveRepositoryCredentials(presetId); final SvnCredentials presetCredentials = resolveRepositoryCredentials(presetId);
if (isBlank(username)) { if (isBlank(username)) {
@@ -387,12 +389,30 @@ public class SettingsService {
password = presetCredentials.getPassword(); password = presetCredentials.getPassword();
} }
} }
if (isBlank(username) || isBlank(password)) {
if (isBlank(username)) {
username = resolveSvnUsername(null);
}
if (isBlank(password)) {
password = resolveSvnPassword(null);
}
}
if (isBlank(username) || isBlank(password)) { if (isBlank(username) || isBlank(password)) {
throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码"); throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码");
} }
return new SvnCredentials(username, password); return new SvnCredentials(username, password);
} }
private String resolveRequestUsername(String requestUsername) {
return isBlank(requestUsername) ? "" : requestUsername.trim();
}
private String resolveRequestPassword(String requestPassword) {
return isBlank(requestPassword) ? "" : requestPassword.trim();
}
public SvnCredentials getConfiguredSvnCredentials() { public SvnCredentials getConfiguredSvnCredentials() {
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null)); return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
} }
@@ -3,15 +3,17 @@ package com.svnlog.web.service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import javax.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.svnlog.web.dto.SvnPresetCreateRequest;
import com.svnlog.web.dto.SvnPresetUpdateRequest;
import com.svnlog.web.model.RepositoryConfig; import com.svnlog.web.model.RepositoryConfig;
import com.svnlog.web.model.SvnPreset; import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.SvnPresetManageItem;
import com.svnlog.web.model.SvnPresetSummary; import com.svnlog.web.model.SvnPresetSummary;
import com.svnlog.web.util.CryptoUtils;
@Service @Service
public class SvnPresetService { public class SvnPresetService {
@@ -23,12 +25,20 @@ public class SvnPresetService {
this.repositoryConfigService = repositoryConfigService; this.repositoryConfigService = repositoryConfigService;
} }
@PostConstruct // ========== 只读接口(供现有抓取页/设置页使用,只允许 enabled 预设) ==========
public void init() {
migrateHardcodedPresets(); /** 返回所有启用预设的摘要列表 */
public List<SvnPresetSummary> listPresetSummaries() {
final List<SvnPreset> presets = listEnabledPresets();
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
for (SvnPreset preset : presets) {
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
}
return summaries;
} }
public List<SvnPreset> listPresets() { /** 返回所有启用预设 */
public List<SvnPreset> listEnabledPresets() {
final List<RepositoryConfig> configs = repositoryConfigService.listByType("SVN"); final List<RepositoryConfig> configs = repositoryConfigService.listByType("SVN");
final List<SvnPreset> presets = new ArrayList<SvnPreset>(); final List<SvnPreset> presets = new ArrayList<SvnPreset>();
for (RepositoryConfig config : configs) { for (RepositoryConfig config : configs) {
@@ -39,14 +49,42 @@ public class SvnPresetService {
return presets; return presets;
} }
public List<SvnPresetSummary> listPresetSummaries() { /** 检查是否存在指定的启用预设 */
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>(); public boolean containsEnabledPresetId(String presetId) {
for (SvnPreset preset : listPresets()) { if (presetId == null || presetId.trim().isEmpty()) {
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName())); return false;
}
try {
final RepositoryConfig config = repositoryConfigService.getById(presetId.trim());
return config.isEnabled();
} catch (IllegalArgumentException e) {
return false;
} }
return summaries;
} }
/** 获取启用预设(throws if disabled or not found */
public SvnPreset getEnabledById(String presetId) {
final RepositoryConfig config = repositoryConfigService.getById(trim(presetId));
if (!config.isEnabled()) {
throw new IllegalArgumentException("仓库已停用: " + presetId);
}
return toSvnPreset(config);
}
/**
* 获取预设(不检查启用状态,仅用于管理接口)
* @deprecated 仅管理接口使用;运行时请使用 getEnabledById()
*/
@Deprecated
public SvnPreset getById(String presetId) {
return toSvnPreset(repositoryConfigService.getById(trim(presetId)));
}
/**
* 检查预设 ID 是否存在(不检查启用状态)
* @deprecated 仅管理接口使用;运行时请使用 containsEnabledPresetId()
*/
@Deprecated
public boolean containsPresetId(String presetId) { public boolean containsPresetId(String presetId) {
if (presetId == null || presetId.trim().isEmpty()) { if (presetId == null || presetId.trim().isEmpty()) {
return false; return false;
@@ -54,12 +92,8 @@ public class SvnPresetService {
return repositoryConfigService.containsId(presetId); return repositoryConfigService.containsId(presetId);
} }
public SvnPreset getById(String presetId) {
return toSvnPreset(repositoryConfigService.getById(trim(presetId)));
}
public String firstPresetId() { public String firstPresetId() {
final List<SvnPreset> presets = listPresets(); final List<SvnPreset> presets = listEnabledPresets();
return presets.isEmpty() ? "" : presets.get(0).getId(); return presets.isEmpty() ? "" : presets.get(0).getId();
} }
@@ -67,6 +101,92 @@ public class SvnPresetService {
return firstPresetId(); return firstPresetId();
} }
// ========== 管理接口 ==========
/** 返回管理列表(含已禁用),字段不含密码明文 */
public List<SvnPresetManageItem> listManage() {
final List<RepositoryConfig> configs = repositoryConfigService.listManage();
final List<SvnPresetManageItem> items = new ArrayList<SvnPresetManageItem>();
for (RepositoryConfig config : configs) {
final boolean credsConfigured = !isBlank(config.getSvnUsername())
&& !isBlank(config.getSvnPasswordEncrypted());
items.add(new SvnPresetManageItem(
config.getId(),
config.getName() == null ? "" : config.getName(),
config.getSvnUrl() == null ? "" : config.getSvnUrl(),
config.getSvnUsername() == null ? "" : config.getSvnUsername(),
credsConfigured,
config.isEnabled(),
config.getCreatedAt(),
config.getLastUsedAt()
));
}
return items;
}
/** 新增预设 */
public SvnPresetManageItem create(SvnPresetCreateRequest request) {
final RepositoryConfig config = new RepositoryConfig();
config.setType("SVN");
config.setName(trim(request.getName()));
config.setSvnUrl(trim(request.getUrl()));
config.setEnabled(request.isEnabled());
config.setSvnUsername(trim(request.getSvnUsername()));
if (!isBlank(request.getSvnPassword())) {
config.setSvnPasswordEncrypted(CryptoUtils.encrypt(trim(request.getSvnPassword())));
}
repositoryConfigService.create(config);
LOGGER.info("新建 SVN 预设: id={} name={}", config.getId(), config.getName());
return toManageItem(config);
}
/** 编辑预设 */
public SvnPresetManageItem update(String id, SvnPresetUpdateRequest request) {
// 显式传入的 name 或 url 不可为空白字符串
if (request.getName() != null && isBlank(request.getName())) {
throw new IllegalArgumentException("名称不能为空");
}
if (request.getUrl() != null && isBlank(request.getUrl())) {
throw new IllegalArgumentException("SVN URL 不能为空");
}
String encryptedPassword = null;
if (!isBlank(request.getSvnPassword())) {
encryptedPassword = CryptoUtils.encrypt(trim(request.getSvnPassword()));
}
String svnUsername = null;
if (request.getSvnUsername() != null && !isBlank(request.getSvnUsername())) {
svnUsername = request.getSvnUsername().trim();
}
repositoryConfigService.update(
id,
request.getName(),
request.getUrl(),
svnUsername,
encryptedPassword,
request.isClearSvnPassword(),
request.getEnabled()
);
LOGGER.info("更新 SVN 预设: id={}", id);
return toManageItem(repositoryConfigService.getById(id));
}
/** 软删除 */
public void delete(String id) {
repositoryConfigService.disable(id);
LOGGER.info("删除 SVN 预设: id={}", id);
}
/** 更新最后使用时间 */
public void touchLastUsedAt(String id) {
repositoryConfigService.updateLastUsedAt(id);
}
public RepositoryConfig getRawConfig(String presetId) {
return repositoryConfigService.getById(trim(presetId));
}
// ========== 内部方法 ==========
private SvnPreset toSvnPreset(RepositoryConfig config) { private SvnPreset toSvnPreset(RepositoryConfig config) {
return new SvnPreset( return new SvnPreset(
config.getId(), config.getId(),
@@ -75,46 +195,26 @@ public class SvnPresetService {
); );
} }
private void migrateHardcodedPresets() { private SvnPresetManageItem toManageItem(RepositoryConfig config) {
final String[][] presets = { final boolean credsConfigured = !isBlank(config.getSvnUsername())
{ && !isBlank(config.getSvnPasswordEncrypted());
"preset-1", return new SvnPresetManageItem(
"PRS-7050场站智慧管控", config.getId(),
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00" config.getName() == null ? "" : config.getName(),
}, config.getSvnUrl() == null ? "" : config.getSvnUrl(),
{ config.getSvnUsername() == null ? "" : config.getSvnUsername(),
"preset-2", credsConfigured,
"PRS-7950在线巡视", config.isEnabled(),
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00" config.getCreatedAt(),
}, config.getLastUsedAt()
{ );
"preset-3",
"PRS-7950在线巡视电科院测试版",
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024"
},
};
int migrated = 0;
int updated = 0;
for (String[] preset : presets) {
if (repositoryConfigService.containsId(preset[0])) {
if (repositoryConfigService.updatePresetUrl(preset[0], preset[2], preset[1])) {
updated++;
}
} else {
repositoryConfigService.migratePreset(preset[0], preset[1], preset[2]);
migrated++;
}
}
if (migrated > 0) {
LOGGER.info("已迁移 {} 个硬编码 SVN 预设到仓库配置", migrated);
}
if (updated > 0) {
LOGGER.info("已更新 {} 个 SVN 预设的 URL", updated);
}
} }
private String trim(String value) { private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private static String trim(String value) {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
} }
@@ -41,7 +41,7 @@ public class SvnWorkflowService {
} }
public void testConnection(SvnConnectionRequest request) throws SVNException { public void testConnection(SvnConnectionRequest request) throws SVNException {
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getEnabledById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(), request.getUsername(),
request.getPassword(), request.getPassword(),
@@ -53,10 +53,11 @@ public class SvnWorkflowService {
credentials.getPassword() credentials.getPassword()
); );
fetcher.testConnection(); fetcher.testConnection();
svnPresetService.touchLastUsedAt(request.getPresetId());
} }
public long[] getVersionRange(SvnVersionRangeRequest request) throws SVNException { public long[] getVersionRange(SvnVersionRangeRequest request) throws SVNException {
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getEnabledById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(), request.getUsername(),
request.getPassword(), request.getPassword(),
@@ -68,16 +69,20 @@ public class SvnWorkflowService {
credentials.getPassword() credentials.getPassword()
); );
final DateRange dateRange = resolveDateRange(request); final DateRange dateRange = resolveDateRange(request);
return fetcher.getVersionRangeByTimeRange( final long[] result = fetcher.getVersionRangeByTimeRange(
dateRange.startInclusive, dateRange.startInclusive,
dateRange.endExclusive, dateRange.endExclusive,
request.getClientTraceId(), request.getClientTraceId(),
dateRange.rangeType dateRange.rangeType
); );
if (result != null) {
svnPresetService.touchLastUsedAt(request.getPresetId());
}
return result;
} }
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception { public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
final SvnPreset preset = svnPresetService.getById(request.getPresetId()); final SvnPreset preset = svnPresetService.getEnabledById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials( final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(), request.getUsername(),
request.getPassword(), request.getPassword(),
@@ -90,6 +95,7 @@ public class SvnWorkflowService {
credentials.getPassword() credentials.getPassword()
); );
fetcher.testConnection(); fetcher.testConnection();
svnPresetService.touchLastUsedAt(request.getPresetId());
context.setProgress(30, "正在拉取 SVN 日志"); context.setProgress(30, "正在拉取 SVN 日志");
final long latest = fetcher.getLatestRevision(); final long latest = fetcher.getLatestRevision();
@@ -0,0 +1,68 @@
package com.svnlog.web.controller;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
import com.svnlog.web.service.OutputFileService;
import com.svnlog.web.service.RepositoryConfigService;
import com.svnlog.web.service.SettingsService;
import com.svnlog.web.service.SvnPresetService;
import com.svnlog.web.service.SvnWorkflowService;
import com.svnlog.web.service.TaskService;
import java.nio.file.Path;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AppControllerPresetTest {
@TempDir
Path tempDir;
@Test
void shouldRejectUpdateWithBlankName() throws Exception {
buildMockMvc()
.perform(put("/api/svn/presets/some-id")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("名称不能为空"));
}
@Test
void shouldRejectUpdateWithBlankUrl() throws Exception {
buildMockMvc()
.perform(put("/api/svn/presets/some-id")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"url\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("SVN URL 不能为空"));
}
private MockMvc buildMockMvc() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final RepositoryConfigService repoConfigService = new RepositoryConfigService(outputFileService);
final SvnPresetService svnPresetService = new SvnPresetService(repoConfigService);
final AppController controller = new AppController(
Mockito.mock(SvnWorkflowService.class),
Mockito.mock(AiWorkflowService.class),
Mockito.mock(TaskService.class),
outputFileService,
Mockito.mock(SettingsService.class),
svnPresetService,
Mockito.mock(HealthService.class)
);
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
}
@@ -144,7 +144,6 @@ class AiWorkflowServiceTest {
private SettingsService buildSettingsService(OutputFileService outputFileService) { private SettingsService buildSettingsService(OutputFileService outputFileService) {
final RepositoryConfigService repositoryConfigService = buildRepositoryConfigService(outputFileService); final RepositoryConfigService repositoryConfigService = buildRepositoryConfigService(outputFileService);
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
return new SettingsService(outputFileService, new SettingsPersistenceService(), svnPresetService, repositoryConfigService); return new SettingsService(outputFileService, new SettingsPersistenceService(), svnPresetService, repositoryConfigService);
} }
@@ -29,7 +29,6 @@ class HealthServiceTest {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init(); repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
final SettingsService settingsService = new SettingsService( final SettingsService settingsService = new SettingsService(
outputFileService, outputFileService,
new SettingsPersistenceService(), new SettingsPersistenceService(),
@@ -42,6 +42,11 @@ class SettingsServiceTest {
void shouldPersistAndReloadSettingsFromOutputDirectory() throws IOException { void shouldPersistAndReloadSettingsFromOutputDirectory() throws IOException {
useTempWorkingDirectory(); useTempWorkingDirectory();
final Path customOutputDir = tempDir.resolve("custom-output"); final Path customOutputDir = tempDir.resolve("custom-output");
final Path initialOutputDir = tempDir.resolve("outputs");
// 创建预设 id=preset-2,用于 defaultSvnPresetId 校验
writeRepositoryConfigToDir(initialOutputDir, "preset-2", "Preset 2", "https://svn.example.com/p2");
writeRepositoryConfigToDir(customOutputDir, "preset-2", "Preset 2", "https://svn.example.com/p2");
final SettingsService settingsService = newSettingsService(); final SettingsService settingsService = newSettingsService();
settingsService.updateSettings( settingsService.updateSettings(
@@ -199,6 +204,45 @@ class SettingsServiceTest {
assertNotNull(settings.get("apiKeySource")); assertNotNull(settings.get("apiKeySource"));
} }
@Test
void shouldPreferPresetCredentialsOverGlobalWhenBothExist() throws IOException {
useTempWorkingDirectory();
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
writeRepositoryConfig(outputFileService.getOutputRoot().resolve("repository-configs.json"));
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
svnPresetService,
repositoryConfigService
);
// 设置全局 SVN 凭据(与预设不同)
settingsService.updateSettings(
"deepseek-key", // apiKey
"deepseek", // provider
null, // openaiBaseUrl
null, // openaiApiKey
null, // openaiStageOneModel
null, // openaiStageTwoModel
"global-user", // svnUsername(全局)
"global-pass", // svnPassword(全局)
null, // outputDir
null // defaultSvnPresetId
);
// 预设 json-user/json-pass 应优先于全局 global-user/global-pass
final SettingsService.SvnCredentials credentials =
settingsService.resolveSvnCredentials(null, null, "preset-json");
assertEquals("json-user", credentials.getUsername());
assertEquals("json-pass", credentials.getPassword());
}
@Test @Test
void shouldResolveSvnCredentialsFromRepositoryConfigWhenSettingsAreEmpty() throws IOException { void shouldResolveSvnCredentialsFromRepositoryConfigWhenSettingsAreEmpty() throws IOException {
useTempWorkingDirectory(); useTempWorkingDirectory();
@@ -209,7 +253,6 @@ class SettingsServiceTest {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init(); repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
final SettingsService settingsService = new SettingsService( final SettingsService settingsService = new SettingsService(
outputFileService, outputFileService,
new SettingsPersistenceService(), new SettingsPersistenceService(),
@@ -233,10 +276,24 @@ class SettingsServiceTest {
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService); final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
repositoryConfigService.init(); repositoryConfigService.init();
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService); final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
svnPresetService.init();
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService, repositoryConfigService); return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService, repositoryConfigService);
} }
private void writeRepositoryConfigToDir(Path outputDir, String id, String name, String url) throws IOException {
final RepositoryConfig config = new RepositoryConfig();
config.setId(id);
config.setName(name);
config.setType("SVN");
config.setEnabled(true);
config.setSvnUrl(url);
config.setSvnUsername("");
final Path configPath = outputDir.resolve("repository-configs.json");
Files.createDirectories(configPath.getParent());
final String json = new GsonBuilder().setPrettyPrinting().create()
.toJson(java.util.Collections.singletonList(config));
Files.write(configPath, json.getBytes(StandardCharsets.UTF_8));
}
private void writeRepositoryConfig(Path configPath) throws IOException { private void writeRepositoryConfig(Path configPath) throws IOException {
final RepositoryConfig config = new RepositoryConfig(); final RepositoryConfig config = new RepositoryConfig();
config.setId("preset-json"); config.setId("preset-json");
@@ -0,0 +1,214 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.svnlog.web.dto.SvnPresetCreateRequest;
import com.svnlog.web.dto.SvnPresetUpdateRequest;
import com.svnlog.web.model.RepositoryConfig;
import com.svnlog.web.model.SvnPresetManageItem;
import com.svnlog.web.util.CryptoUtils;
public class SvnPresetServiceTest {
private static RepositoryConfigService createRepoService(Path outputRoot) throws IOException {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(outputRoot.toString());
return new RepositoryConfigService(outputFileService);
}
@Test
public void shouldCreatePresetAndPersistToJson() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("test-project");
request.setUrl("https://svn.example.com/test");
request.setSvnUsername("testuser");
request.setSvnPassword("secret123");
final SvnPresetManageItem result = service.create(request);
Assertions.assertNotNull(result.getId());
Assertions.assertEquals("test-project", result.getName());
Assertions.assertEquals("https://svn.example.com/test", result.getUrl());
Assertions.assertEquals("testuser", result.getSvnUsername());
Assertions.assertTrue(result.isSvnCredentialsConfigured());
Assertions.assertTrue(result.isEnabled());
// 验证密码加密保存且不回显明文
final RepositoryConfig raw = repoService.getById(result.getId());
Assertions.assertFalse(raw.getSvnPasswordEncrypted().isEmpty());
Assertions.assertNotEquals("secret123", raw.getSvnPasswordEncrypted());
Assertions.assertEquals("secret123", CryptoUtils.decrypt(raw.getSvnPasswordEncrypted()));
// 验证管理列表返回用户名但不返回密码
final SvnPresetManageItem manageItem = service.listManage().get(0);
Assertions.assertEquals("testuser", manageItem.getSvnUsername());
// Password is never exposed in the manage item
}
@Test
public void shouldUpdatePresetWithoutOverwritingPasswordWhenEmpty() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-update-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
// Create preset with password
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("user1");
request.setSvnPassword("original-password");
final SvnPresetManageItem created = service.create(request);
final String encryptedBefore = repoService.getById(created.getId()).getSvnPasswordEncrypted();
// Update without password — should not overwrite
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setName("updated-project");
updateReq.setUrl("https://svn.example.com/updated");
updateReq.setSvnUsername("user2");
// svnPassword is null — should not overwrite
updateReq.setEnabled(true);
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("updated-project", updated.getName());
Assertions.assertEquals("https://svn.example.com/updated", updated.getSvnUrl());
Assertions.assertEquals("user2", updated.getSvnUsername());
// Password should remain unchanged
Assertions.assertEquals(encryptedBefore, updated.getSvnPasswordEncrypted());
Assertions.assertEquals("original-password", CryptoUtils.decrypt(updated.getSvnPasswordEncrypted()));
}
@Test
public void shouldNotOverwriteUsernameWithEmptyString() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-username-empty-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("existing-user");
request.setSvnPassword("pass");
final SvnPresetManageItem created = service.create(request);
// Update with empty username — should NOT overwrite
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setSvnUsername("");
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("existing-user", updated.getSvnUsername());
}
@Test
public void shouldNotOverwriteUsernameWithWhitespace() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-username-whitespace-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("existing-user");
request.setSvnPassword("pass");
final SvnPresetManageItem created = service.create(request);
// Update with whitespace-only username — should NOT overwrite
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setSvnUsername(" ");
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("existing-user", updated.getSvnUsername());
}
@Test
public void shouldUpdateUsernameWhenValid() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-username-valid-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnUsername("old-user");
request.setSvnPassword("pass");
final SvnPresetManageItem created = service.create(request);
// Update with valid non-empty username
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setSvnUsername("new-user");
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertEquals("new-user", updated.getSvnUsername());
}
@Test
public void shouldClearPasswordWhenClearSvnPasswordIsTrue() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-clear-pwd-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("project");
request.setUrl("https://svn.example.com/project");
request.setSvnPassword("sekret");
final SvnPresetManageItem created = service.create(request);
// Clear password
final SvnPresetUpdateRequest updateReq = new SvnPresetUpdateRequest();
updateReq.setClearSvnPassword(true);
service.update(created.getId(), updateReq);
final RepositoryConfig updated = repoService.getById(created.getId());
Assertions.assertTrue(updated.getSvnPasswordEncrypted() == null
|| updated.getSvnPasswordEncrypted().isEmpty());
Assertions.assertFalse(service.listManage().get(0).isSvnCredentialsConfigured());
}
@Test
public void shouldSoftDeletePreset() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-delete-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
final SvnPresetCreateRequest request = new SvnPresetCreateRequest();
request.setName("to-delete");
request.setUrl("https://svn.example.com/todelete");
final SvnPresetManageItem created = service.create(request);
Assertions.assertTrue(created.isEnabled());
service.delete(created.getId());
// After soft delete, disabled item should not appear in summary list
final java.util.List<com.svnlog.web.model.SvnPresetSummary> summaries = service.listPresetSummaries();
Assertions.assertTrue(summaries.isEmpty());
// But still visible in manage list with enabled=false
final SvnPresetManageItem manageItem = service.listManage().get(0);
Assertions.assertFalse(manageItem.isEnabled());
}
@Test
public void shouldReturnEmptyWhenNoPresetsConfigured() throws Exception {
final Path tempDir = Files.createTempDirectory("svn-preset-empty-test");
final RepositoryConfigService repoService = createRepoService(tempDir);
final SvnPresetService service = new SvnPresetService(repoService);
Assertions.assertTrue(service.listPresetSummaries().isEmpty());
Assertions.assertTrue(service.listManage().isEmpty());
Assertions.assertEquals("", service.firstPresetId());
}
}