feat: add svn preset management and optimize docker builds
This commit is contained in:
@@ -2,3 +2,7 @@ target/
|
|||||||
.git/
|
.git/
|
||||||
.idea/
|
.idea/
|
||||||
outputs/
|
outputs/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/node/
|
||||||
|
outputs.nobody-backup-*/
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`)。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 与输出目录' } },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user