重构项目结构,移除旧Java客户端,添加前后端目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
liumangmang
2026-02-09 17:22:26 +08:00
parent ddeb7c65ff
commit 9ee9c96a91
71 changed files with 4893 additions and 2943 deletions

69
backend/pom.xml Normal file
View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.svnmanager</groupId>
<artifactId>svn-manager-backend</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>SVN Manager Backend</name>
<description>SVN 管理工具 - REST API 后端</description>
<properties>
<java.version>17</java.version>
<jackson.version>2.15.2</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>com.tmatesoft.svnkit</groupId>
<artifactId>svnkit</artifactId>
<version>1.10.13</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
package com.svnmanager;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SvnManagerApplication {
public static void main(String[] args) {
SpringApplication.run(SvnManagerApplication.class, args);
}
}

View File

@@ -0,0 +1,23 @@
package com.svnmanager.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOriginPatterns(List.of("*"));
config.addAllowedHeader("*");
config.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,40 @@
package com.svnmanager.controller;
import com.svnmanager.model.Project;
import com.svnmanager.util.ConfigUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
@GetMapping
public List<Project> list() {
return ConfigUtil.loadProjects();
}
@PostMapping
public ResponseEntity<?> add(@RequestBody Project project) {
if (project.getName() == null || project.getName().trim().isEmpty()
|| project.getPath() == null || project.getPath().trim().isEmpty()) {
return ResponseEntity.badRequest().body("项目名称和路径不能为空");
}
boolean saved = ConfigUtil.addProject(project);
return saved ? ResponseEntity.ok(project) : ResponseEntity.internalServerError().build();
}
@PutMapping
public ResponseEntity<?> update(@RequestBody Project project) {
boolean ok = ConfigUtil.updateProject(project);
return ok ? ResponseEntity.ok(project) : ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable String id) {
boolean ok = ConfigUtil.deleteProject(id);
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
}

View File

@@ -0,0 +1,156 @@
package com.svnmanager.controller;
import com.svnmanager.model.FileTreeItem;
import com.svnmanager.model.Project;
import com.svnmanager.model.SvnLog;
import com.svnmanager.model.SvnStatus;
import com.svnmanager.service.*;
import com.svnmanager.util.ConfigUtil;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@RestController
@RequestMapping("/api/svn")
public class SvnController {
private final StatusService statusService;
private final UpdateService updateService;
private final CommitService commitService;
private final LogService logService;
private final DiffService diffService;
private final InfoService infoService;
private final FileTreeService fileTreeService;
public SvnController(StatusService statusService, UpdateService updateService,
CommitService commitService, LogService logService,
DiffService diffService, InfoService infoService,
FileTreeService fileTreeService) {
this.statusService = statusService;
this.updateService = updateService;
this.commitService = commitService;
this.logService = logService;
this.diffService = diffService;
this.infoService = infoService;
this.fileTreeService = fileTreeService;
}
@GetMapping("/status/{projectId}")
public ResponseEntity<?> status(@PathVariable String projectId) {
Project project = ConfigUtil.getProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
try {
SvnStatus status = statusService.getStatus(project.getPath(), project.getUsername(), project.getPassword());
FileTreeItem fileTree = fileTreeService.buildFileTree(project.getPath(), status);
return ResponseEntity.ok(new StatusResponse(status, fileTree));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
@PostMapping("/update/{projectId}")
public ResponseEntity<?> update(@PathVariable String projectId) {
Project project = ConfigUtil.getProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
try {
UpdateService.UpdateResult r = updateService.update(project.getPath(), project.getUsername(), project.getPassword());
return ResponseEntity.ok(r);
} catch (Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
@PostMapping("/commit/{projectId}")
public ResponseEntity<?> commit(@PathVariable String projectId, @RequestBody CommitRequest body) {
Project project = ConfigUtil.getProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
if (body == null || body.message == null || body.message.trim().isEmpty()) {
return ResponseEntity.badRequest().body("提交消息不能为空");
}
try {
CommitService.CommitResult r = commitService.commit(project.getPath(), body.message,
project.getUsername(), project.getPassword());
return ResponseEntity.ok(r);
} catch (Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
@GetMapping("/log/{projectId}")
public ResponseEntity<?> log(@PathVariable String projectId,
@RequestParam(defaultValue = "20") int limit) {
Project project = ConfigUtil.getProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
try {
List<SvnLog> logs = logService.getLog(project.getPath(), limit, project.getUsername(), project.getPassword());
return ResponseEntity.ok(logs);
} catch (Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
@GetMapping("/diff/{projectId}")
public ResponseEntity<?> diff(@PathVariable String projectId,
@RequestParam(required = false) String path) {
Project project = ConfigUtil.getProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
try {
String diff = diffService.getDiff(project.getPath(), path, project.getUsername(), project.getPassword());
return ResponseEntity.ok(new DiffResponse(diff));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
@GetMapping(value = "/file/{projectId}", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<?> fileContent(@PathVariable String projectId,
@RequestParam String path) {
Project project = ConfigUtil.getProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
if (path == null || path.trim().isEmpty()) {
return ResponseEntity.badRequest().body("path 不能为空");
}
try {
Path projectPath = Paths.get(project.getPath()).toAbsolutePath().normalize();
Path filePath = projectPath.resolve(path.replace("\\", "/")).normalize();
if (!filePath.startsWith(projectPath)) {
return ResponseEntity.badRequest().body("无效路径");
}
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
return ResponseEntity.notFound().build();
}
String content = Files.readString(filePath, StandardCharsets.UTF_8);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE + "; charset=UTF-8")
.body(content);
} catch (IOException e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
public static class StatusResponse {
public SvnStatus status;
public FileTreeItem fileTree;
public StatusResponse(SvnStatus status, FileTreeItem fileTree) {
this.status = status;
this.fileTree = fileTree;
}
}
public static class CommitRequest {
public String message;
}
public static class DiffResponse {
public String diff;
public DiffResponse(String diff) { this.diff = diff; }
}
}

View File

@@ -0,0 +1,81 @@
package com.svnmanager.model;
import java.util.ArrayList;
import java.util.List;
/**
* 文件树节点模型
* 用于TreeView展示文件和目录的层级结构
*/
public class FileTreeItem {
private String name;
private String path;
private boolean isDirectory;
private SvnFileStatus.FileStatus status;
private List<FileTreeItem> children;
public FileTreeItem(String name, String path, boolean isDirectory) {
this.name = name;
this.path = path;
this.isDirectory = isDirectory;
this.status = SvnFileStatus.FileStatus.NORMAL;
this.children = new ArrayList<>();
}
public FileTreeItem(String name, String path, boolean isDirectory, SvnFileStatus.FileStatus status) {
this.name = name;
this.path = path;
this.isDirectory = isDirectory;
this.status = status;
this.children = new ArrayList<>();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public boolean isDirectory() {
return isDirectory;
}
public void setDirectory(boolean directory) {
isDirectory = directory;
}
public SvnFileStatus.FileStatus getStatus() {
return status;
}
public void setStatus(SvnFileStatus.FileStatus status) {
this.status = status;
}
public List<FileTreeItem> getChildren() {
return children;
}
public void setChildren(List<FileTreeItem> children) {
this.children = children;
}
public void addChild(FileTreeItem child) {
this.children.add(child);
}
@Override
public String toString() {
return name;
}
}

View File

@@ -0,0 +1,139 @@
package com.svnmanager.model;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 项目数据模型
*/
public class Project {
@JsonProperty("id")
private String id;
@JsonProperty("name")
private String name;
@JsonProperty("path")
private String path;
@JsonProperty("svnUrl")
private String svnUrl;
@JsonProperty("username")
private String username;
@JsonProperty("password")
private String password;
@JsonProperty("currentVersion")
private String currentVersion;
@JsonProperty("workingCopyVersion")
private String workingCopyVersion;
@JsonProperty("status")
private ProjectStatus status;
public Project() {
this.status = ProjectStatus.UNKNOWN;
}
public Project(String id, String name, String path, String svnUrl) {
this.id = id;
this.name = name;
this.path = path;
this.svnUrl = svnUrl;
this.status = ProjectStatus.UNKNOWN;
}
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 getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getSvnUrl() {
return svnUrl;
}
public void setSvnUrl(String svnUrl) {
this.svnUrl = svnUrl;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getCurrentVersion() {
return currentVersion;
}
public void setCurrentVersion(String currentVersion) {
this.currentVersion = currentVersion;
}
public String getWorkingCopyVersion() {
return workingCopyVersion;
}
public void setWorkingCopyVersion(String workingCopyVersion) {
this.workingCopyVersion = workingCopyVersion;
}
public ProjectStatus getStatus() {
return status;
}
public void setStatus(ProjectStatus status) {
this.status = status;
}
/**
* 项目状态枚举
*/
public enum ProjectStatus {
SYNCED("已同步"),
UPDATES_AVAILABLE("有更新"),
DISCONNECTED("未连接"),
UNKNOWN("未知");
private final String displayName;
ProjectStatus(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
}

View File

@@ -0,0 +1,91 @@
package com.svnmanager.model;
/**
* SVN文件状态模型
*/
public class SvnFileStatus {
private String path;
private FileStatus status;
private String workingCopyStatus;
private String repositoryStatus;
public SvnFileStatus() {
}
public SvnFileStatus(String path, FileStatus status) {
this.path = path;
this.status = status;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public FileStatus getStatus() {
return status;
}
public void setStatus(FileStatus status) {
this.status = status;
}
public String getWorkingCopyStatus() {
return workingCopyStatus;
}
public void setWorkingCopyStatus(String workingCopyStatus) {
this.workingCopyStatus = workingCopyStatus;
}
public String getRepositoryStatus() {
return repositoryStatus;
}
public void setRepositoryStatus(String repositoryStatus) {
this.repositoryStatus = repositoryStatus;
}
/**
* 文件状态枚举
*/
public enum FileStatus {
MODIFIED('M', "已修改"),
ADDED('A', "已添加"),
DELETED('D', "已删除"),
CONFLICTED('C', "冲突"),
UNVERSIONED('?', "未版本控制"),
MISSING('!', "缺失"),
EXTERNAL('X', "外部"),
IGNORED('I', "已忽略"),
NORMAL(' ', "正常");
private final char code;
private final String displayName;
FileStatus(char code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public char getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static FileStatus fromCode(char code) {
for (FileStatus status : values()) {
if (status.code == code) {
return status;
}
}
return NORMAL;
}
}
}

View File

@@ -0,0 +1,100 @@
package com.svnmanager.model;
/**
* SVN信息模型
*/
public class SvnInfo {
private String path;
private String url;
private String repositoryRoot;
private String repositoryUuid;
private String revision;
private String nodeKind;
private String schedule;
private String lastChangedAuthor;
private String lastChangedRev;
private String lastChangedDate;
public SvnInfo() {
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getRepositoryRoot() {
return repositoryRoot;
}
public void setRepositoryRoot(String repositoryRoot) {
this.repositoryRoot = repositoryRoot;
}
public String getRepositoryUuid() {
return repositoryUuid;
}
public void setRepositoryUuid(String repositoryUuid) {
this.repositoryUuid = repositoryUuid;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
public String getNodeKind() {
return nodeKind;
}
public void setNodeKind(String nodeKind) {
this.nodeKind = nodeKind;
}
public String getSchedule() {
return schedule;
}
public void setSchedule(String schedule) {
this.schedule = schedule;
}
public String getLastChangedAuthor() {
return lastChangedAuthor;
}
public void setLastChangedAuthor(String lastChangedAuthor) {
this.lastChangedAuthor = lastChangedAuthor;
}
public String getLastChangedRev() {
return lastChangedRev;
}
public void setLastChangedRev(String lastChangedRev) {
this.lastChangedRev = lastChangedRev;
}
public String getLastChangedDate() {
return lastChangedDate;
}
public void setLastChangedDate(String lastChangedDate) {
this.lastChangedDate = lastChangedDate;
}
}

View File

@@ -0,0 +1,72 @@
package com.svnmanager.model;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* SVN日志模型
*/
public class SvnLog {
private String revision;
private String author;
private LocalDateTime date;
private String message;
private List<String> changedPaths;
public SvnLog() {
this.changedPaths = new ArrayList<>();
}
public SvnLog(String revision, String author, LocalDateTime date, String message) {
this.revision = revision;
this.author = author;
this.date = date;
this.message = message;
this.changedPaths = new ArrayList<>();
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public LocalDateTime getDate() {
return date;
}
public void setDate(LocalDateTime date) {
this.date = date;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public List<String> getChangedPaths() {
return changedPaths;
}
public void setChangedPaths(List<String> changedPaths) {
this.changedPaths = changedPaths;
}
public void addChangedPath(String path) {
this.changedPaths.add(path);
}
}

View File

@@ -0,0 +1,95 @@
package com.svnmanager.model;
import java.util.ArrayList;
import java.util.List;
/**
* SVN状态模型
*/
public class SvnStatus {
private String workingCopyPath;
private String revision;
private List<SvnFileStatus> files;
private int modifiedCount;
private int addedCount;
private int deletedCount;
private int conflictedCount;
public SvnStatus() {
this.files = new ArrayList<>();
}
public String getWorkingCopyPath() {
return workingCopyPath;
}
public void setWorkingCopyPath(String workingCopyPath) {
this.workingCopyPath = workingCopyPath;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
public List<SvnFileStatus> getFiles() {
return files;
}
public void setFiles(List<SvnFileStatus> files) {
this.files = files;
updateCounts();
}
public void addFile(SvnFileStatus file) {
this.files.add(file);
updateCounts();
}
public int getModifiedCount() {
return modifiedCount;
}
public int getAddedCount() {
return addedCount;
}
public int getDeletedCount() {
return deletedCount;
}
public int getConflictedCount() {
return conflictedCount;
}
public int getTotalChangedFiles() {
return modifiedCount + addedCount + deletedCount + conflictedCount;
}
private void updateCounts() {
modifiedCount = 0;
addedCount = 0;
deletedCount = 0;
conflictedCount = 0;
for (SvnFileStatus file : files) {
switch (file.getStatus()) {
case MODIFIED:
modifiedCount++;
break;
case ADDED:
addedCount++;
break;
case DELETED:
deletedCount++;
break;
case CONFLICTED:
conflictedCount++;
break;
}
}
}
}

View File

@@ -0,0 +1,46 @@
package com.svnmanager.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNURL;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.SVNDepth;
import org.tmatesoft.svn.core.wc.SVNRevision;
import java.io.File;
/**
* Checkout 服务(基于 SVNKit
*/
@Service
public class CheckoutService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(CheckoutService.class);
public boolean checkout(String svnUrl, String targetPath, String revision) {
logger.info("检出仓库: {} 到 {}", svnUrl, targetPath);
SVNRevision rev = (revision != null && !revision.isEmpty())
? SVNRevision.create(Long.parseLong(revision.replaceAll("\\D", "")))
: SVNRevision.HEAD;
SVNClientManager clientManager = createClientManager(null, null);
try {
SVNURL url = SVNURL.parseURIEncoded(svnUrl);
File target = new File(targetPath);
clientManager.getUpdateClient().doCheckout(url, target, rev, rev, SVNDepth.INFINITY, true);
logger.info("检出成功");
return true;
} catch (SVNException e) {
logger.error("检出失败: {}", e.getMessage());
return false;
} finally {
clientManager.dispose();
}
}
public boolean checkout(String svnUrl, String targetPath) {
return checkout(svnUrl, targetPath, null);
}
}

View File

@@ -0,0 +1,115 @@
package com.svnmanager.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNCommitInfo;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.SVNDepth;
import java.io.File;
import java.util.List;
/**
* Commit 服务(基于 SVNKit
*/
@Service
public class CommitService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(CommitService.class);
public CommitResult commit(String workingDirectory, String message, List<String> files,
String username, String password) {
logger.info("提交修改: {}", workingDirectory);
if (!isValidWorkingCopy(workingDirectory)) {
throw new IllegalArgumentException("无效的SVN工作副本: " + workingDirectory);
}
if (message == null || message.trim().isEmpty()) {
throw new IllegalArgumentException("提交消息不能为空");
}
File wcRoot = new File(workingDirectory);
File[] commitPaths = (files != null && !files.isEmpty())
? files.stream().map(f -> new File(wcRoot, f)).toArray(File[]::new)
: new File[]{wcRoot};
CommitResult commitResult = new CommitResult();
SVNClientManager clientManager = createClientManager(username, password);
try {
SVNCommitInfo info = clientManager.getCommitClient().doCommit(
commitPaths,
true,
message,
null,
null,
false,
false,
SVNDepth.INFINITY);
commitResult.setSuccess(true);
if (info.getNewRevision() >= 0) {
commitResult.setRevision(String.valueOf(info.getNewRevision()));
}
logger.info("提交成功,版本: {}", commitResult.getRevision());
} catch (SVNException e) {
logger.error("提交失败: {}", e.getMessage());
commitResult.setSuccess(false);
commitResult.setError(e.getMessage());
} finally {
clientManager.dispose();
}
return commitResult;
}
public CommitResult commit(String workingDirectory, String message, String username, String password) {
return commit(workingDirectory, message, null, username, password);
}
public CommitResult commit(String workingDirectory, String message) {
return commit(workingDirectory, message, null, null);
}
/**
* 提交结果
*/
public static class CommitResult {
private boolean success;
private String revision;
private String output;
private String error;
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
public String getOutput() {
return output;
}
public void setOutput(String output) {
this.output = output;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
}
}

View File

@@ -0,0 +1,67 @@
package com.svnmanager.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.SVNDepth;
import org.tmatesoft.svn.core.wc.SVNRevision;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
/**
* Diff 服务(基于 SVNKit
*/
@Service
public class DiffService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(DiffService.class);
public String getDiff(String workingDirectory, String filePath, String username, String password) {
logger.debug("获取差异: {}", workingDirectory);
if (!isValidWorkingCopy(workingDirectory)) {
throw new IllegalArgumentException("无效的SVN工作副本: " + workingDirectory);
}
File wcRoot = new File(workingDirectory);
File[] paths = (filePath != null && !filePath.isEmpty())
? new File[]{new File(wcRoot, filePath)}
: new File[]{wcRoot};
// 使用双路径形式 (path@BASE vs path@WORKING),无需访问远程仓库,避免 E195002 pegged diff 错误
ByteArrayOutputStream out = new ByteArrayOutputStream();
SVNClientManager clientManager = createClientManager(username, password);
try {
for (File path : paths) {
clientManager.getDiffClient().doDiff(
path,
SVNRevision.BASE,
path,
SVNRevision.WORKING,
SVNDepth.INFINITY,
false,
out,
null);
}
return out.toString(StandardCharsets.UTF_8.name());
} catch (SVNException e) {
logger.warn("获取差异失败: {}", e.getMessage());
throw new RuntimeException(e);
} catch (java.io.UnsupportedEncodingException e) {
throw new RuntimeException(e);
} finally {
clientManager.dispose();
}
}
public String getDiff(String workingDirectory) {
return getDiff(workingDirectory, null, null, null);
}
public String getDiff(String workingDirectory, String filePath) {
return getDiff(workingDirectory, filePath, null, null);
}
}

View File

@@ -0,0 +1,59 @@
package com.svnmanager.service;
import com.svnmanager.model.FileTreeItem;
import com.svnmanager.model.SvnFileStatus;
import com.svnmanager.model.SvnStatus;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
@Service
public class FileTreeService {
public FileTreeItem buildFileTree(String projectPath, SvnStatus status) {
File projectDir = new File(projectPath);
FileTreeItem root = new FileTreeItem(projectDir.getName(), projectPath, true);
root.setStatus(SvnFileStatus.FileStatus.NORMAL);
Map<String, FileTreeItem> pathMap = new HashMap<>();
pathMap.put(projectPath, root);
for (SvnFileStatus fileStatus : status.getFiles()) {
String relativePath = fileStatus.getPath();
File file = new File(projectPath, relativePath);
String fullPath = file.getAbsolutePath();
String name = file.getName();
FileTreeItem parent = ensureParentExists(file.getParentFile(), pathMap, projectPath);
FileTreeItem node = new FileTreeItem(name, fullPath, file.isDirectory(), fileStatus.getStatus());
parent.getChildren().add(node);
pathMap.put(fullPath, node);
}
sortTree(root);
return root;
}
private FileTreeItem ensureParentExists(File parent, Map<String, FileTreeItem> pathMap, String projectPath) {
if (parent == null) return pathMap.get(projectPath);
String parentPath = parent.getAbsolutePath();
if (parentPath.equals(projectPath)) return pathMap.get(projectPath);
if (pathMap.containsKey(parentPath)) return pathMap.get(parentPath);
FileTreeItem grandParent = ensureParentExists(parent.getParentFile(), pathMap, projectPath);
FileTreeItem dirItem = new FileTreeItem(parent.getName(), parentPath, true);
grandParent.getChildren().add(dirItem);
pathMap.put(parentPath, dirItem);
return dirItem;
}
private void sortTree(FileTreeItem item) {
if (item.getChildren().isEmpty()) return;
item.getChildren().sort(Comparator
.comparing(FileTreeItem::isDirectory, Comparator.reverseOrder())
.thenComparing(FileTreeItem::getName, String.CASE_INSENSITIVE_ORDER));
item.getChildren().forEach(this::sortTree);
}
}

View File

@@ -0,0 +1,81 @@
package com.svnmanager.service;
import com.svnmanager.model.SvnInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNNodeKind;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.wc.SVNRevision;
import org.tmatesoft.svn.core.wc.SVNInfo;
import java.io.File;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* Info 服务(基于 SVNKit
*/
@Service
public class InfoService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(InfoService.class);
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public SvnInfo getInfo(String workingDirectory, String username, String password) {
logger.debug("获取信息: {}", workingDirectory);
if (!isValidWorkingCopy(workingDirectory)) {
throw new IllegalArgumentException("无效的SVN工作副本: " + workingDirectory);
}
SVNClientManager clientManager = createClientManager(username, password);
try {
SVNInfo info = clientManager.getWCClient().doInfo(new File(workingDirectory), SVNRevision.WORKING);
return mapToSvnInfo(info, workingDirectory);
} catch (SVNException e) {
logger.warn("获取信息失败: {}", e.getMessage());
throw new RuntimeException(e);
} finally {
clientManager.dispose();
}
}
public SvnInfo getInfo(String workingDirectory) {
return getInfo(workingDirectory, null, null);
}
private static SvnInfo mapToSvnInfo(SVNInfo info, String workingDirectory) {
SvnInfo out = new SvnInfo();
if (info.getFile() != null) {
out.setPath(info.getFile().getAbsolutePath());
} else {
out.setPath(workingDirectory);
}
if (info.getURL() != null) {
out.setUrl(info.getURL().toString());
}
if (info.getRepositoryRootURL() != null) {
out.setRepositoryRoot(info.getRepositoryRootURL().toString());
}
out.setRepositoryUuid(info.getRepositoryUUID());
if (info.getRevision() != null && info.getRevision().isValid()) {
out.setRevision(String.valueOf(info.getRevision().getNumber()));
}
SVNNodeKind kind = info.getKind();
if (kind != null) {
out.setNodeKind(kind.toString());
}
String schedule = info.getSchedule();
out.setSchedule(schedule != null ? schedule : "normal");
out.setLastChangedAuthor(info.getAuthor());
if (info.getCommittedRevision() != null && info.getCommittedRevision().isValid()) {
out.setLastChangedRev(String.valueOf(info.getCommittedRevision().getNumber()));
}
if (info.getCommittedDate() != null) {
out.setLastChangedDate(LocalDateTime.ofInstant(info.getCommittedDate().toInstant(), ZoneId.systemDefault()).format(DATE_FORMAT));
}
return out;
}
}

View File

@@ -0,0 +1,81 @@
package com.svnmanager.service;
import com.svnmanager.model.SvnLog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNLogEntryPath;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.wc.SVNRevision;
import java.io.File;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Log 服务(基于 SVNKit
*/
@Service
public class LogService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(LogService.class);
public List<SvnLog> getLog(String workingDirectory, Integer limit, String username, String password) {
logger.debug("获取日志: {}", workingDirectory);
if (!isValidWorkingCopy(workingDirectory)) {
throw new IllegalArgumentException("无效的SVN工作副本: " + workingDirectory);
}
List<SvnLog> logs = new ArrayList<>();
File wcRoot = new File(workingDirectory);
int limitVal = (limit != null && limit > 0) ? limit : 50;
SVNClientManager clientManager = createClientManager(username, password);
try {
clientManager.getLogClient().doLog(
new File[]{wcRoot},
SVNRevision.HEAD,
SVNRevision.create(0),
SVNRevision.HEAD,
true,
true,
false,
limitVal,
null,
entry -> {
SvnLog log = new SvnLog();
log.setRevision(String.valueOf(entry.getRevision()));
log.setAuthor(entry.getAuthor());
if (entry.getDate() != null) {
log.setDate(LocalDateTime.ofInstant(entry.getDate().toInstant(), ZoneId.systemDefault()));
}
log.setMessage(entry.getMessage() != null ? entry.getMessage() : "");
if (entry.getChangedPaths() != null) {
for (Map.Entry<String, SVNLogEntryPath> e : entry.getChangedPaths().entrySet()) {
log.addChangedPath(e.getKey());
}
}
logs.add(log);
});
} catch (SVNException e) {
logger.warn("获取日志失败: {}", e.getMessage());
throw new RuntimeException(e);
} finally {
clientManager.dispose();
}
return logs;
}
public List<SvnLog> getLog(String workingDirectory, String username, String password) {
return getLog(workingDirectory, 50, username, password);
}
public List<SvnLog> getLog(String workingDirectory) {
return getLog(workingDirectory, 50, null, null);
}
}

View File

@@ -0,0 +1,83 @@
package com.svnmanager.service;
import com.svnmanager.model.SvnFileStatus;
import com.svnmanager.model.SvnStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.wc.SVNStatus;
import org.tmatesoft.svn.core.wc.SVNStatusType;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Status 服务(基于 SVNKit
*/
@Service
public class StatusService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(StatusService.class);
public SvnStatus getStatus(String workingDirectory, String username, String password) {
logger.debug("获取状态: {}", workingDirectory);
if (!isValidWorkingCopy(workingDirectory)) {
throw new IllegalArgumentException("无效的SVN工作副本: " + workingDirectory);
}
File wcRoot = new File(workingDirectory);
String wcRootPath = wcRoot.getAbsolutePath();
List<SvnFileStatus> files = new ArrayList<>();
SVNClientManager clientManager = createClientManager(username, password);
try {
// reportAll=true: 列出所有版本化/未版本化项,与 svn status -v 一致,供右侧文件树完整展示
clientManager.getStatusClient().doStatus(wcRoot, true, false, true, false, new org.tmatesoft.svn.core.wc.ISVNStatusHandler() {
@Override
public void handleStatus(SVNStatus status) {
SvnFileStatus.FileStatus fs = mapStatusType(status.getContentsStatus());
File file = status.getFile();
if (file == null) return;
String absPath = file.getAbsolutePath();
String relativePath = absPath.startsWith(wcRootPath)
? absPath.substring(wcRootPath.length()).replace(File.separatorChar, '/').replaceAll("^/", "")
: file.getName();
if (relativePath.isEmpty()) return;
SvnFileStatus f = new SvnFileStatus(relativePath, fs);
f.setWorkingCopyStatus(String.valueOf(fs.getCode()));
files.add(f);
}
});
} catch (SVNException e) {
logger.warn("获取状态失败: {}", e.getMessage());
throw new RuntimeException(e);
} finally {
clientManager.dispose();
}
SvnStatus status = new SvnStatus();
status.setWorkingCopyPath(workingDirectory);
status.setFiles(files);
return status;
}
public SvnStatus getStatus(String workingDirectory) {
return getStatus(workingDirectory, null, null);
}
private static SvnFileStatus.FileStatus mapStatusType(SVNStatusType t) {
if (t == null) return SvnFileStatus.FileStatus.NORMAL;
if (t == SVNStatusType.STATUS_MODIFIED) return SvnFileStatus.FileStatus.MODIFIED;
if (t == SVNStatusType.STATUS_ADDED) return SvnFileStatus.FileStatus.ADDED;
if (t == SVNStatusType.STATUS_DELETED) return SvnFileStatus.FileStatus.DELETED;
if (t == SVNStatusType.STATUS_CONFLICTED) return SvnFileStatus.FileStatus.CONFLICTED;
if (t == SVNStatusType.STATUS_UNVERSIONED) return SvnFileStatus.FileStatus.UNVERSIONED;
if (t == SVNStatusType.STATUS_MISSING) return SvnFileStatus.FileStatus.MISSING;
if (t == SVNStatusType.STATUS_IGNORED) return SvnFileStatus.FileStatus.IGNORED;
if (t == SVNStatusType.STATUS_EXTERNAL) return SvnFileStatus.FileStatus.EXTERNAL;
return SvnFileStatus.FileStatus.NORMAL;
}
}

View File

@@ -0,0 +1,53 @@
package com.svnmanager.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.wc.SVNWCUtil;
import java.io.File;
/**
* SVN服务基类基于 SVNKit
*/
public abstract class SvnService {
protected static final Logger logger = LoggerFactory.getLogger(SvnService.class);
/**
* 创建带可选认证的 SVNClientManager。
*
* @param username 用户名(可为空)
* @param password 密码(可为空)
* @return SVNClientManager 实例,调用方使用后需 dispose
*/
protected SVNClientManager createClientManager(String username, String password) {
if (username != null && !username.isEmpty()) {
return SVNClientManager.newInstance(SVNWCUtil.createDefaultOptions(true), username, password != null ? password : "");
}
return SVNClientManager.newInstance(SVNWCUtil.createDefaultOptions(true));
}
/**
* 验证工作目录是否为有效的 SVN 工作副本(基于 SVNKit
*/
protected boolean isValidWorkingCopy(String workingDirectory) {
if (workingDirectory == null || workingDirectory.isEmpty()) {
return false;
}
File dir = new File(workingDirectory);
if (!dir.isDirectory()) {
return false;
}
SVNClientManager clientManager = createClientManager(null, null);
try {
clientManager.getStatusClient().doStatus(dir, false);
return true;
} catch (SVNException e) {
logger.debug("验证工作副本失败: {}", e.getMessage());
return false;
} finally {
clientManager.dispose();
}
}
}

View File

@@ -0,0 +1,99 @@
package com.svnmanager.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.SVNDepth;
import org.tmatesoft.svn.core.wc.SVNRevision;
import java.io.File;
/**
* Update 服务(基于 SVNKit
*/
@Service
public class UpdateService extends SvnService {
private static final Logger logger = LoggerFactory.getLogger(UpdateService.class);
public UpdateResult update(String workingDirectory, String revision, String username, String password) {
logger.info("更新工作副本: {}", workingDirectory);
if (!isValidWorkingCopy(workingDirectory)) {
throw new IllegalArgumentException("无效的SVN工作副本: " + workingDirectory);
}
File wcRoot = new File(workingDirectory);
SVNRevision targetRevision = (revision != null && !revision.isEmpty())
? SVNRevision.create(Long.parseLong(revision.replaceAll("\\D", "")))
: SVNRevision.HEAD;
UpdateResult updateResult = new UpdateResult();
SVNClientManager clientManager = createClientManager(username, password);
try {
long rev = clientManager.getUpdateClient().doUpdate(wcRoot, targetRevision, SVNDepth.INFINITY, true, true);
updateResult.setSuccess(true);
updateResult.setRevision(String.valueOf(rev));
logger.info("更新成功,版本: {}", rev);
} catch (SVNException e) {
logger.error("更新失败: {}", e.getMessage());
updateResult.setSuccess(false);
updateResult.setError(e.getMessage());
} finally {
clientManager.dispose();
}
return updateResult;
}
public UpdateResult update(String workingDirectory, String username, String password) {
return update(workingDirectory, null, username, password);
}
public UpdateResult update(String workingDirectory) {
return update(workingDirectory, null, null, null);
}
/**
* 更新结果
*/
public static class UpdateResult {
private boolean success;
private String revision;
private String output;
private String error;
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getRevision() {
return revision;
}
public void setRevision(String revision) {
this.revision = revision;
}
public String getOutput() {
return output;
}
public void setOutput(String output) {
this.output = output;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
}
}

View File

@@ -0,0 +1,162 @@
package com.svnmanager.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.svnmanager.model.Project;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 配置管理工具类
*/
public class ConfigUtil {
private static final Logger logger = LoggerFactory.getLogger(ConfigUtil.class);
private static final String CONFIG_DIR = System.getProperty("user.home") + File.separator + ".svn-manager";
private static final String PROJECTS_FILE = CONFIG_DIR + File.separator + "projects.json";
private static final ObjectMapper objectMapper = new ObjectMapper();
static {
// 确保配置目录存在
try {
Path configPath = Paths.get(CONFIG_DIR);
if (!Files.exists(configPath)) {
Files.createDirectories(configPath);
}
} catch (IOException e) {
logger.error("创建配置目录失败", e);
}
}
/**
* 加载项目列表
*
* @return 项目列表
*/
public static List<Project> loadProjects() {
File file = new File(PROJECTS_FILE);
if (!file.exists()) {
logger.info("项目配置文件不存在,返回空列表");
return new ArrayList<>();
}
try {
CollectionType listType = objectMapper.getTypeFactory()
.constructCollectionType(List.class, Project.class);
List<Project> projects = objectMapper.readValue(file, listType);
logger.info("成功加载 {} 个项目", projects.size());
return projects;
} catch (IOException e) {
logger.error("加载项目配置失败", e);
return new ArrayList<>();
}
}
/**
* 保存项目列表
*
* @param projects 项目列表
* @return 是否保存成功
*/
public static boolean saveProjects(List<Project> projects) {
File file = new File(PROJECTS_FILE);
try {
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file, projects);
logger.info("成功保存 {} 个项目", projects.size());
return true;
} catch (IOException e) {
logger.error("保存项目配置失败", e);
return false;
}
}
/**
* 添加项目
*
* @param project 项目
* @return 是否添加成功
*/
public static boolean addProject(Project project) {
if (project.getId() == null || project.getId().isEmpty()) {
project.setId(UUID.randomUUID().toString());
}
List<Project> projects = loadProjects();
projects.add(project);
return saveProjects(projects);
}
/**
* 更新项目
*
* @param project 项目
* @return 是否更新成功
*/
public static boolean updateProject(Project project) {
List<Project> projects = loadProjects();
for (int i = 0; i < projects.size(); i++) {
if (projects.get(i).getId().equals(project.getId())) {
Project existing = projects.get(i);
// 密码留空时保留原密码
if (project.getPassword() == null || project.getPassword().isEmpty()) {
project.setPassword(existing.getPassword());
}
projects.set(i, project);
return saveProjects(projects);
}
}
return false;
}
/**
* 删除项目
*
* @param projectId 项目ID
* @return 是否删除成功
*/
public static boolean deleteProject(String projectId) {
List<Project> projects = loadProjects();
projects.removeIf(p -> p.getId().equals(projectId));
return saveProjects(projects);
}
/**
* 根据ID获取项目
*
* @param projectId 项目ID
* @return 项目如果不存在返回null
*/
public static Project getProjectById(String projectId) {
List<Project> projects = loadProjects();
return projects.stream()
.filter(p -> p.getId().equals(projectId))
.findFirst()
.orElse(null);
}
/**
* 获取配置目录路径
*
* @return 配置目录路径
*/
public static String getConfigDir() {
return CONFIG_DIR;
}
/**
* 获取项目配置文件路径
*
* @return 项目配置文件路径
*/
public static String getProjectsFilePath() {
return PROJECTS_FILE;
}
}

View File

@@ -0,0 +1,51 @@
package com.svnmanager.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 日志工具类
*/
public class LogUtil {
/**
* 获取Logger实例
*
* @param clazz 类
* @return Logger实例
*/
public static Logger getLogger(Class<?> clazz) {
return LoggerFactory.getLogger(clazz);
}
/**
* 记录错误日志
*
* @param logger Logger实例
* @param message 消息
* @param throwable 异常
*/
public static void logError(Logger logger, String message, Throwable throwable) {
logger.error(message, throwable);
}
/**
* 记录信息日志
*
* @param logger Logger实例
* @param message 消息
*/
public static void logInfo(Logger logger, String message) {
logger.info(message);
}
/**
* 记录调试日志
*
* @param logger Logger实例
* @param message 消息
*/
public static void logDebug(Logger logger, String message) {
logger.debug(message);
}
}

View File

@@ -0,0 +1,2 @@
server.port=8080
spring.application.name=svn-manager-backend

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${user.home}/.svn-manager/svn-manager.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${user.home}/.svn-manager/svn-manager.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<logger name="com.svnmanager" level="DEBUG" />
</configuration>