feat: add svn preset management and optimize docker builds
This commit is contained in:
@@ -2,3 +2,7 @@ target/
|
||||
.git/
|
||||
.idea/
|
||||
outputs/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/node/
|
||||
outputs.nobody-backup-*/
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- 核心目录:
|
||||
- `src/main/java/com/svnlog/`
|
||||
- `docs/`
|
||||
- SVN 预设地址:`src/main/resources/application.properties`(`svn.presets[*]`)
|
||||
- SVN 预设地址:通过仓库管理页维护,持久化至 `outputs/repository-configs.json`
|
||||
|
||||
## 2. 常用命令(Build / Lint / Test / Run)
|
||||
以下命令默认在仓库根目录执行。
|
||||
|
||||
+23
-11
@@ -1,9 +1,11 @@
|
||||
# ============================================================
|
||||
# Docker 镜像仓库加速(默认使用 docker.1ms.run 国内代理)
|
||||
# 如需切换回 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
|
||||
# 内部/开发用途,推荐通过 make fast-up 触发快速构建
|
||||
ARG FAST_BUILD=false
|
||||
|
||||
# ============================================================
|
||||
# Stage 1: 前端构建(Vue 3 + Vite)
|
||||
@@ -14,7 +16,7 @@ WORKDIR /frontend
|
||||
|
||||
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/ ./
|
||||
|
||||
@@ -23,20 +25,20 @@ RUN npm run build
|
||||
# ============================================================
|
||||
# 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 调优:增大堆内存、启用并行
|
||||
ENV MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m -Djava.util.concurrent.ForkJoinPool.common.parallelism=4"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 使用阿里云 Maven 镜像加速依赖下载(替换 Maven Central)
|
||||
COPY maven-settings.xml /root/.m2/settings.xml
|
||||
# 使用阿里云 Maven 镜像加速依赖下载(避免被 /root/.m2 缓存挂载点隐藏)
|
||||
COPY maven-settings.xml /app/maven-settings.xml
|
||||
|
||||
COPY pom.xml .
|
||||
|
||||
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
|
||||
|
||||
@@ -44,11 +46,21 @@ COPY src ./src
|
||||
# vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend
|
||||
COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2
|
||||
|
||||
# 前端产物已由 frontend-builder 阶段构建并 COPY 进来;
|
||||
# 此阶段不含 frontend-vue/,且离线模式无法下载 Node,必须跳过前端构建。
|
||||
# -T 1C: 按 CPU 核数并行; -o: 离线模式(依赖已缓存,跳过元数据检查)
|
||||
# 默认构建分支(不缓存 /app/target,执行 clean)
|
||||
FROM builder-base AS builder-false
|
||||
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)
|
||||
@@ -56,7 +68,7 @@ RUN --mount=type=cache,target=/root/.m2 \
|
||||
FROM ${REGISTRY_MIRROR}/eclipse-temurin:8-jre-alpine
|
||||
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
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
# ============================================================
|
||||
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)
|
||||
BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
up:
|
||||
@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"
|
||||
|
||||
down:
|
||||
@@ -23,3 +23,10 @@ status:
|
||||
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
|
||||
@$(BUILD_ENV) $(COMPOSE_CMD) ps
|
||||
@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"
|
||||
|
||||
@@ -9,9 +9,12 @@ SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 一键启动(Docker,每次会重新构建镜像并打包最新代码)
|
||||
# 一键启动(Docker,每次会重新构建镜像并打包最新代码,安全默认构建)
|
||||
make up
|
||||
|
||||
# 快速开发迭代启动(Docker,保留 Java 编译缓存,仅适合本地 Java 增量开发)
|
||||
make fast-up
|
||||
|
||||
# 查看状态
|
||||
make status
|
||||
|
||||
|
||||
+4
-3
@@ -4,9 +4,10 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Docker 镜像加速(默认 Docker Hub,国内可设阿里云)
|
||||
# 使用方式: REGISTRY_MIRROR=registry.cn-hangzhou.aliyuncs.com/library docker compose build
|
||||
REGISTRY_MIRROR: ${REGISTRY_MIRROR:-docker.1ms.run/library}
|
||||
# Docker 镜像加速(使用 DOCKER_REGISTRY_MIRROR 传递,国内可选阿里云,例如 registry.cn-hangzhou.aliyuncs.com/library,不能带 https://)
|
||||
# 内部/开发用途,推荐通过 make fast-up 使用快速构建
|
||||
REGISTRY_MIRROR: ${DOCKER_REGISTRY_MIRROR:-docker.1ms.run/library}
|
||||
FAST_BUILD: ${FAST_BUILD:-false}
|
||||
container_name: svn-log-tool
|
||||
network_mode: host
|
||||
volumes:
|
||||
|
||||
+16
-8
@@ -34,12 +34,19 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
||||
http://localhost:18088
|
||||
```
|
||||
|
||||
## Docker 构建行为
|
||||
## Docker 构建行为与优化
|
||||
|
||||
- `make up` 保持“重新构建并启动”的语义,每次都会执行一次 Maven 打包,确保容器内是最新代码。
|
||||
- Docker 构建使用 BuildKit 缓存 Maven 本地仓库;首次构建会下载依赖,后续在 `pom.xml` 未变更时会优先命中缓存,不会在每次构建时重复下载全部依赖。
|
||||
- 如果修改了 `pom.xml`、执行了 `docker builder prune`、或切换到新的 Docker 环境,依赖缓存会失效并重新下载。
|
||||
- 如果本机 Docker 未启用 BuildKit,可显式设置 `DOCKER_BUILDKIT=1` 和 `COMPOSE_DOCKER_CLI_BUILD=1` 后再执行 `make up`。
|
||||
- **Docker 镜像加速**:默认使用 `docker.1ms.run/library` 代理。如果需要切换回 Docker Hub 或使用国内其他镜像(如阿里云镜像,例如 `registry.cn-hangzhou.aliyuncs.com/library`,注意不能带 `https://`),可以通过命令行传递 `DOCKER_REGISTRY_MIRROR` 变量:
|
||||
```bash
|
||||
DOCKER_REGISTRY_MIRROR=registry.cn-hangzhou.aliyuncs.com/library 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 凭据读取优先级
|
||||
|
||||
1. 单次请求显式传入的 `username/password`(兼容旧接口)
|
||||
2. 设置页保存的运行时 `svnUsername/svnPassword`
|
||||
3. 环境变量 `SVN_USERNAME` / `SVN_PASSWORD`
|
||||
2. 预设凭据
|
||||
3. 全局设置保存的运行时 `svnUsername/svnPassword`
|
||||
4. 环境变量 `SVN_USERNAME` / `SVN_PASSWORD`
|
||||
|
||||
`GET /api/settings` 不会回显 `openaiApiKey` 或 `svnPassword` 明文,前端通过 `openaiApiKeyConfigured` 和 `svnCredentialsConfigured` 展示配置状态。
|
||||
|
||||
@@ -105,7 +113,7 @@ http://localhost:18088
|
||||
|
||||
## SVN 预设来源与调用方式
|
||||
|
||||
- SVN 地址统一维护在 `application.properties` 的 `svn.presets[*]` 中。
|
||||
- SVN 地址统一通过仓库管理页维护,并持久化到 `outputs/repository-configs.json` 文件中。
|
||||
- 前端不再传 SVN URL,业务接口统一传 `presetId`,后端按 `presetId` 解析地址。
|
||||
- `GET /api/svn/presets` 仅返回 `id` 与 `name`(不返回 `url`)。
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
</router-link>
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
@@ -5,13 +5,15 @@ import DashboardView from './views/DashboardView.vue'
|
||||
import SvnFetchView from './views/SvnFetchView.vue'
|
||||
import HistoryView from './views/HistoryView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import SvnPresetsView from './views/SvnPresetsView.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ 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: '/presets', name: 'presets', component: SvnPresetsView, meta: { title: '仓库管理', desc: '管理 SVN 仓库预设' } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<template>
|
||||
<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">
|
||||
<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 批量抓取参数
|
||||
</div>
|
||||
<div class="alert alert-info" style="margin-bottom:var(--space-md);">
|
||||
默认已填充 3 个常用项目路径,可选择月份自动填充版本号,或手动填写。
|
||||
支持多个项目批量抓取,可先选月份自动填充版本号,或手动填写。
|
||||
</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);">
|
||||
@@ -114,6 +124,7 @@ const { toast } = useToast()
|
||||
|
||||
const presets = ref([])
|
||||
const defaultPresetId = ref('')
|
||||
const loading = ref(true)
|
||||
const testing = ref(false)
|
||||
const running = ref(false)
|
||||
const autoFillLoading = ref(false)
|
||||
@@ -166,7 +177,8 @@ onMounted(async () => {
|
||||
startRevision: '',
|
||||
endRevision: '',
|
||||
}))
|
||||
} catch (err) { toast(err.message, true) }
|
||||
loading.value = false
|
||||
} catch (err) { loading.value = false; toast(err.message, true) }
|
||||
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
|
||||
@@ -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.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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.SvnConnectionRequest;
|
||||
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.model.SvnPreset;
|
||||
import com.svnlog.web.model.SvnPresetManageItem;
|
||||
import com.svnlog.web.model.SvnPresetSummary;
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskPageResult;
|
||||
@@ -100,7 +105,7 @@ public class AppController {
|
||||
@PostMapping("/svn/version-range")
|
||||
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
|
||||
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 SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
|
||||
request.getUsername(),
|
||||
@@ -168,6 +173,56 @@ public class AppController {
|
||||
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")
|
||||
public Map<String, String> analyzeLogs(@Valid @RequestBody AiAnalyzeRequest request) {
|
||||
final String taskId = taskService.submit("AI_ANALYZE", context -> aiWorkflowService.analyzeAndExport(request, context));
|
||||
@@ -304,6 +359,14 @@ public class AppController {
|
||||
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 {
|
||||
final String fileName = file.getFileName().toString().toLowerCase();
|
||||
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.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
@@ -82,58 +83,118 @@ public class RepositoryConfigService {
|
||||
return CryptoUtils.decrypt(config.getSvnPasswordEncrypted());
|
||||
}
|
||||
|
||||
public synchronized boolean updatePresetUrl(String presetId, String newUrl, String name) {
|
||||
if (!containsId(presetId)) {
|
||||
return false;
|
||||
}
|
||||
final RepositoryConfig existing = getById(presetId);
|
||||
if (newUrl.equals(existing.getSvnUrl())) {
|
||||
return false;
|
||||
}
|
||||
final String oldUrl = existing.getSvnUrl();
|
||||
existing.setSvnUrl(newUrl);
|
||||
existing.setName(name);
|
||||
save();
|
||||
LOGGER.info("更新 SVN 预设 URL: id={} name={} oldUrl={} newUrl={}", presetId, name, oldUrl, newUrl);
|
||||
return true;
|
||||
/**
|
||||
* 创建新的仓库配置
|
||||
* @param config 已填充的配置对象(id 已生成)
|
||||
*/
|
||||
public synchronized void create(RepositoryConfig config) {
|
||||
config.setCreatedAt(System.currentTimeMillis());
|
||||
final List<RepositoryConfig> newList = new ArrayList<RepositoryConfig>(configs);
|
||||
newList.add(config);
|
||||
persistAndSwap(newList);
|
||||
LOGGER.info("新建仓库配置: id={} name={}", config.getId(), config.getName());
|
||||
}
|
||||
|
||||
public synchronized void migratePreset(String presetId, String name, String url) {
|
||||
if (containsId(presetId)) {
|
||||
// 已存在,检查 URL 是否有变化,有则更新
|
||||
final RepositoryConfig existing = getById(presetId);
|
||||
if (!url.equals(existing.getSvnUrl())) {
|
||||
final String oldUrl = existing.getSvnUrl();
|
||||
existing.setSvnUrl(url);
|
||||
existing.setName(name);
|
||||
save();
|
||||
LOGGER.info("更新 SVN 预设 URL: id={} name={} oldUrl={} newUrl={}", presetId, name, oldUrl, url);
|
||||
/**
|
||||
* 更新已有仓库配置的字段。密码为空表示不覆盖。
|
||||
*/
|
||||
public synchronized void update(String id, String name, String url,
|
||||
String svnUsername, String svnPasswordEncrypted,
|
||||
boolean clearSvnPassword, Boolean enabled) {
|
||||
final RepositoryConfig existing = getById(id);
|
||||
// deep-copy: serialize then deserialize so we never mutate the shared object before write
|
||||
final RepositoryConfig copy = gson.fromJson(gson.toJson(existing), RepositoryConfig.class);
|
||||
if (name != null && !name.trim().isEmpty()) {
|
||||
copy.setName(name.trim());
|
||||
}
|
||||
return;
|
||||
if (url != null && !url.trim().isEmpty()) {
|
||||
copy.setSvnUrl(url.trim());
|
||||
}
|
||||
final RepositoryConfig config = new RepositoryConfig();
|
||||
config.setId(presetId);
|
||||
config.setName(name);
|
||||
config.setType("SVN");
|
||||
config.setEnabled(true);
|
||||
config.setSvnUrl(url);
|
||||
config.setCreatedAt(System.currentTimeMillis());
|
||||
configs.add(config);
|
||||
save();
|
||||
LOGGER.info("迁移 SVN 预设到仓库配置: id={} name={}", presetId, name);
|
||||
if (svnUsername != null && !isBlank(svnUsername)) {
|
||||
copy.setSvnUsername(svnUsername.trim());
|
||||
}
|
||||
if (clearSvnPassword) {
|
||||
copy.setSvnPasswordEncrypted("");
|
||||
} else if (svnPasswordEncrypted != null && !svnPasswordEncrypted.isEmpty()) {
|
||||
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() {
|
||||
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 {
|
||||
final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME);
|
||||
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) {
|
||||
LOGGER.warn("保存仓库配置失败", e);
|
||||
throw new IllegalStateException("保存仓库配置失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ public class SettingsService {
|
||||
if (outputDir != null && !outputDir.trim().isEmpty()) {
|
||||
outputFileService.setOutputRoot(outputDir);
|
||||
}
|
||||
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) {
|
||||
if (svnPresetService.containsEnabledPresetId(newDefaultSvnPresetId)) {
|
||||
this.defaultSvnPresetId = newDefaultSvnPresetId;
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ public class SettingsService {
|
||||
if (!isBlank(persistedSettings.getOutputDir())) {
|
||||
outputFileService.setOutputRoot(persistedSettings.getOutputDir());
|
||||
}
|
||||
if (svnPresetService.containsPresetId(persistedSettings.getDefaultSvnPresetId())) {
|
||||
if (svnPresetService.containsEnabledPresetId(persistedSettings.getDefaultSvnPresetId())) {
|
||||
this.defaultSvnPresetId = persistedSettings.getDefaultSvnPresetId().trim();
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ public class SettingsService {
|
||||
}
|
||||
|
||||
public String getDefaultSvnPresetId() {
|
||||
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
|
||||
if (svnPresetService.containsEnabledPresetId(defaultSvnPresetId)) {
|
||||
return defaultSvnPresetId;
|
||||
}
|
||||
return svnPresetService.firstPresetId();
|
||||
@@ -376,8 +376,10 @@ public class SettingsService {
|
||||
}
|
||||
|
||||
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)) {
|
||||
final SvnCredentials presetCredentials = resolveRepositoryCredentials(presetId);
|
||||
if (isBlank(username)) {
|
||||
@@ -387,12 +389,30 @@ public class SettingsService {
|
||||
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)) {
|
||||
throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码");
|
||||
}
|
||||
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() {
|
||||
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@ package com.svnlog.web.service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.SvnPreset;
|
||||
import com.svnlog.web.model.SvnPresetManageItem;
|
||||
import com.svnlog.web.model.SvnPresetSummary;
|
||||
import com.svnlog.web.util.CryptoUtils;
|
||||
|
||||
@Service
|
||||
public class SvnPresetService {
|
||||
@@ -23,12 +25,20 @@ public class SvnPresetService {
|
||||
this.repositoryConfigService = repositoryConfigService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
migrateHardcodedPresets();
|
||||
// ========== 只读接口(供现有抓取页/设置页使用,只允许 enabled 预设) ==========
|
||||
|
||||
/** 返回所有启用预设的摘要列表 */
|
||||
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<SvnPreset> presets = new ArrayList<SvnPreset>();
|
||||
for (RepositoryConfig config : configs) {
|
||||
@@ -39,14 +49,42 @@ public class SvnPresetService {
|
||||
return presets;
|
||||
}
|
||||
|
||||
public List<SvnPresetSummary> listPresetSummaries() {
|
||||
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
|
||||
for (SvnPreset preset : listPresets()) {
|
||||
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
|
||||
/** 检查是否存在指定的启用预设 */
|
||||
public boolean containsEnabledPresetId(String presetId) {
|
||||
if (presetId == null || presetId.trim().isEmpty()) {
|
||||
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) {
|
||||
if (presetId == null || presetId.trim().isEmpty()) {
|
||||
return false;
|
||||
@@ -54,12 +92,8 @@ public class SvnPresetService {
|
||||
return repositoryConfigService.containsId(presetId);
|
||||
}
|
||||
|
||||
public SvnPreset getById(String presetId) {
|
||||
return toSvnPreset(repositoryConfigService.getById(trim(presetId)));
|
||||
}
|
||||
|
||||
public String firstPresetId() {
|
||||
final List<SvnPreset> presets = listPresets();
|
||||
final List<SvnPreset> presets = listEnabledPresets();
|
||||
return presets.isEmpty() ? "" : presets.get(0).getId();
|
||||
}
|
||||
|
||||
@@ -67,6 +101,92 @@ public class SvnPresetService {
|
||||
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) {
|
||||
return new SvnPreset(
|
||||
config.getId(),
|
||||
@@ -75,46 +195,26 @@ public class SvnPresetService {
|
||||
);
|
||||
}
|
||||
|
||||
private void migrateHardcodedPresets() {
|
||||
final String[][] presets = {
|
||||
{
|
||||
"preset-1",
|
||||
"PRS-7050场站智慧管控",
|
||||
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00"
|
||||
},
|
||||
{
|
||||
"preset-2",
|
||||
"PRS-7950在线巡视",
|
||||
"https://10.6.223.170:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00"
|
||||
},
|
||||
{
|
||||
"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 SvnPresetManageItem toManageItem(RepositoryConfig config) {
|
||||
final boolean credsConfigured = !isBlank(config.getSvnUsername())
|
||||
&& !isBlank(config.getSvnPasswordEncrypted());
|
||||
return 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()
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class SvnWorkflowService {
|
||||
}
|
||||
|
||||
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(
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
@@ -53,10 +53,11 @@ public class SvnWorkflowService {
|
||||
credentials.getPassword()
|
||||
);
|
||||
fetcher.testConnection();
|
||||
svnPresetService.touchLastUsedAt(request.getPresetId());
|
||||
}
|
||||
|
||||
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(
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
@@ -68,16 +69,20 @@ public class SvnWorkflowService {
|
||||
credentials.getPassword()
|
||||
);
|
||||
final DateRange dateRange = resolveDateRange(request);
|
||||
return fetcher.getVersionRangeByTimeRange(
|
||||
final long[] result = fetcher.getVersionRangeByTimeRange(
|
||||
dateRange.startInclusive,
|
||||
dateRange.endExclusive,
|
||||
request.getClientTraceId(),
|
||||
dateRange.rangeType
|
||||
);
|
||||
if (result != null) {
|
||||
svnPresetService.touchLastUsedAt(request.getPresetId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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(
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
@@ -90,6 +95,7 @@ public class SvnWorkflowService {
|
||||
credentials.getPassword()
|
||||
);
|
||||
fetcher.testConnection();
|
||||
svnPresetService.touchLastUsedAt(request.getPresetId());
|
||||
|
||||
context.setProgress(30, "正在拉取 SVN 日志");
|
||||
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) {
|
||||
final RepositoryConfigService repositoryConfigService = buildRepositoryConfigService(outputFileService);
|
||||
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
|
||||
svnPresetService.init();
|
||||
return new SettingsService(outputFileService, new SettingsPersistenceService(), svnPresetService, repositoryConfigService);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ class HealthServiceTest {
|
||||
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
|
||||
repositoryConfigService.init();
|
||||
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
|
||||
svnPresetService.init();
|
||||
final SettingsService settingsService = new SettingsService(
|
||||
outputFileService,
|
||||
new SettingsPersistenceService(),
|
||||
|
||||
@@ -42,6 +42,11 @@ class SettingsServiceTest {
|
||||
void shouldPersistAndReloadSettingsFromOutputDirectory() throws IOException {
|
||||
useTempWorkingDirectory();
|
||||
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();
|
||||
settingsService.updateSettings(
|
||||
@@ -199,6 +204,45 @@ class SettingsServiceTest {
|
||||
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
|
||||
void shouldResolveSvnCredentialsFromRepositoryConfigWhenSettingsAreEmpty() throws IOException {
|
||||
useTempWorkingDirectory();
|
||||
@@ -209,7 +253,6 @@ class SettingsServiceTest {
|
||||
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
|
||||
repositoryConfigService.init();
|
||||
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
|
||||
svnPresetService.init();
|
||||
final SettingsService settingsService = new SettingsService(
|
||||
outputFileService,
|
||||
new SettingsPersistenceService(),
|
||||
@@ -233,10 +276,24 @@ class SettingsServiceTest {
|
||||
final RepositoryConfigService repositoryConfigService = new RepositoryConfigService(outputFileService);
|
||||
repositoryConfigService.init();
|
||||
final SvnPresetService svnPresetService = new SvnPresetService(repositoryConfigService);
|
||||
svnPresetService.init();
|
||||
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 {
|
||||
final RepositoryConfig config = new RepositoryConfig();
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user