feat: add dashboard and virtual pay enhancements

This commit is contained in:
王鹏
2026-07-29 14:34:54 +08:00
parent 2250f744d1
commit 349f1c0374
55 changed files with 3085 additions and 213 deletions

View File

@@ -174,10 +174,13 @@ public class TtCopyTemplateController extends BaseController {
content = content.replace("{projectName}", projectName);
content = content.replace("{codeDesc}", plainDesc);
content = content.replace("{codeEnvironment}", code.getCodeEnvironment() != null ? code.getCodeEnvironment() : "");
content = content.replace("{frontendTechnology}", code.getFrontendTechnology() != null ? code.getFrontendTechnology() : "");
content = content.replace("{backendTechnology}", code.getBackendTechnology() != null ? code.getBackendTechnology() : "");
content = content.replace("{databaseTechnology}", code.getDatabaseTechnology() != null ? code.getDatabaseTechnology() : "");
content = content.replace("{codeTechnology}", code.getCodeTechnology() != null ? code.getCodeTechnology() : "");
content = content.replace("{diskLink}", code.getDiskLink() != null ? code.getDiskLink() : "");
content = content.replace("{screenshots}", screenshots);
return success(content);
}
}
}

View File

@@ -8,6 +8,7 @@ import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.utils.CoverGenerator;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.IProjectLinkCheckService;
@@ -20,7 +21,9 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -70,6 +73,18 @@ public class TtProjectInfoController extends BaseController {
util.exportExcel(response, list, "项目清单数据");
}
/**
* 按项目名称导入夸克或百度网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "导入项目网盘链接", businessType = BusinessType.IMPORT)
@PostMapping("/import-links")
public AjaxResult importLinks(@RequestParam("file") MultipartFile file,
@RequestParam("diskType") String diskType) {
ProjectLinkImportResult result = ttProjectInfoService.importProjectLinks(file, diskType);
return AjaxResult.success(result.buildMessage(), result);
}
/**
* 获取项目清单详细信息
*/

View File

@@ -0,0 +1,71 @@
package com.ruoyi.office.domain;
/**
* 项目网盘链接导入明细。
*/
public class ProjectLinkImportDetail
{
/** 原文件中的行号。 */
private int rowNumber;
/** 项目名称。 */
private String projectName;
/** SKIPPED / FAILED。 */
private String resultType;
/** 跳过或失败原因。 */
private String reason;
public ProjectLinkImportDetail()
{
}
public ProjectLinkImportDetail(int rowNumber, String projectName, String resultType, String reason)
{
this.rowNumber = rowNumber;
this.projectName = projectName;
this.resultType = resultType;
this.reason = reason;
}
public int getRowNumber()
{
return rowNumber;
}
public void setRowNumber(int rowNumber)
{
this.rowNumber = rowNumber;
}
public String getProjectName()
{
return projectName;
}
public void setProjectName(String projectName)
{
this.projectName = projectName;
}
public String getResultType()
{
return resultType;
}
public void setResultType(String resultType)
{
this.resultType = resultType;
}
public String getReason()
{
return reason;
}
public void setReason(String reason)
{
this.reason = reason;
}
}

View File

@@ -0,0 +1,98 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 项目网盘链接导入结果。
*/
public class ProjectLinkImportResult
{
private int totalCount;
private int addedCount;
private int updatedCount;
private int unchangedCount;
private int skippedCount;
private int failedCount;
private final List<ProjectLinkImportDetail> details = new ArrayList<>();
public void addSkipped(int rowNumber, String projectName, String reason)
{
skippedCount++;
details.add(new ProjectLinkImportDetail(rowNumber, projectName, "SKIPPED", reason));
}
public void addFailed(int rowNumber, String projectName, String reason)
{
failedCount++;
details.add(new ProjectLinkImportDetail(rowNumber, projectName, "FAILED", reason));
}
public String buildMessage()
{
return "导入完成:共 " + totalCount + " 条,新增 " + addedCount + " 条,更新 "
+ updatedCount + " 条,未变化 " + unchangedCount + " 条,跳过 "
+ skippedCount + " 条,失败 " + failedCount + "";
}
public int getTotalCount()
{
return totalCount;
}
public void setTotalCount(int totalCount)
{
this.totalCount = totalCount;
}
public int getAddedCount()
{
return addedCount;
}
public void incrementAddedCount()
{
addedCount++;
}
public int getUpdatedCount()
{
return updatedCount;
}
public void incrementUpdatedCount()
{
updatedCount++;
}
public int getUnchangedCount()
{
return unchangedCount;
}
public void incrementUnchangedCount()
{
unchangedCount++;
}
public int getSkippedCount()
{
return skippedCount;
}
public int getFailedCount()
{
return failedCount;
}
public List<ProjectLinkImportDetail> getDetails()
{
return details;
}
}

View File

@@ -0,0 +1,67 @@
package com.ruoyi.office.domain;
/**
* 项目网盘链接导入文件中的一行原始数据。
*/
public class ProjectLinkImportRow
{
private int rowNumber;
private String projectName;
private String shareAddress;
private String extractCode;
private String shareStatus;
public int getRowNumber()
{
return rowNumber;
}
public void setRowNumber(int rowNumber)
{
this.rowNumber = rowNumber;
}
public String getProjectName()
{
return projectName;
}
public void setProjectName(String projectName)
{
this.projectName = projectName;
}
public String getShareAddress()
{
return shareAddress;
}
public void setShareAddress(String shareAddress)
{
this.shareAddress = shareAddress;
}
public String getExtractCode()
{
return extractCode;
}
public void setExtractCode(String extractCode)
{
this.extractCode = extractCode;
}
public String getShareStatus()
{
return shareStatus;
}
public void setShareStatus(String shareStatus)
{
this.shareStatus = shareStatus;
}
}

View File

@@ -40,11 +40,29 @@ public class TtCode extends BaseEntity {
private String codeEnvironment;
/**
* 项目技术
* 其他技术
*/
@Excel(name = "项目技术")
@Excel(name = "其他技术")
private String codeTechnology;
/**
* 前端
*/
@Excel(name = "前端")
private String frontendTechnology;
/**
* 后端
*/
@Excel(name = "后端")
private String backendTechnology;
/**
* 数据库
*/
@Excel(name = "数据库")
private String databaseTechnology;
/**
* 来源
*/
@@ -123,6 +141,30 @@ public class TtCode extends BaseEntity {
return codeTechnology;
}
public void setFrontendTechnology(String frontendTechnology) {
this.frontendTechnology = frontendTechnology;
}
public String getFrontendTechnology() {
return frontendTechnology;
}
public void setBackendTechnology(String backendTechnology) {
this.backendTechnology = backendTechnology;
}
public String getBackendTechnology() {
return backendTechnology;
}
public void setDatabaseTechnology(String databaseTechnology) {
this.databaseTechnology = databaseTechnology;
}
public String getDatabaseTechnology() {
return databaseTechnology;
}
public void setCodeSource(String codeSource) {
this.codeSource = codeSource;
}
@@ -187,6 +229,9 @@ public class TtCode extends BaseEntity {
.append("codeDesc", getCodeDesc())
.append("codeEnvironment", getCodeEnvironment())
.append("codeTechnology", getCodeTechnology())
.append("frontendTechnology", getFrontendTechnology())
.append("backendTechnology", getBackendTechnology())
.append("databaseTechnology", getDatabaseTechnology())
.append("codeSource", getCodeSource())
.append("paymentType", getPaymentType())
.append("diskLink", getDiskLink())

View File

@@ -2,6 +2,7 @@ package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtProjectInfo;
import org.apache.ibatis.annotations.Param;
/**
* 项目清单Mapper接口
@@ -62,4 +63,8 @@ public interface TtProjectInfoMapper
List<TtProjectInfo> lastUpdateList(String searchKey);
TtProjectInfo selectTtProjectInfoByName(String codeName);
int updateProjectQuarkUrl(@Param("id") Integer id, @Param("projectUrl") String projectUrl);
int updateProjectBaiduUrl(@Param("id") Integer id, @Param("projectBaiduUrl") String projectBaiduUrl);
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.office.mapper;
import com.ruoyi.office.domain.TtProjectLinkCheck;
import org.apache.ibatis.annotations.Param;
/**
* 项目网盘链接检测结果 Mapper。
@@ -12,4 +13,7 @@ public interface TtProjectLinkCheckMapper
int deleteByProjectId(Integer projectId);
int deleteByProjectIds(Integer[] projectIds);
int deleteByProjectIdAndDiskType(@Param("projectId") Integer projectId,
@Param("diskType") String diskType);
}

View File

@@ -2,6 +2,8 @@ package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import org.springframework.web.multipart.MultipartFile;
/**
* 项目清单Service接口
@@ -64,4 +66,13 @@ public interface ITtProjectInfoService
List<?> lastUpdateList(String searchKey);
TtProjectInfo selectTtProjectInfoByName(String codeName);
/**
* 导入夸克或百度网盘分享链接。
*
* @param file 网盘客户端导出的 CSV/Excel 文件
* @param diskType QUARK/BAIDU
* @return 导入结果
*/
ProjectLinkImportResult importProjectLinks(MultipartFile file, String diskType);
}

View File

@@ -149,7 +149,7 @@ public class TtCodeServiceImpl implements ITtCodeService {
content.append(ttCode.getCodeDesc());
content.append("<p><strong>### 运行环境</strong></p>");
content.append(ttCode.getCodeEnvironment());
content.append("<p><strong>### 项目技术</strong></p>");
content.append("<p><strong>### 其他技术</strong></p>");
content.append(ttCode.getCodeTechnology());
ttArticles.setContent(content.toString());
ttArticlesMapper.insertTtArticles(ttArticles);
@@ -168,7 +168,7 @@ public class TtCodeServiceImpl implements ITtCodeService {
content.append(ttCode.getCodeDesc());
content.append("<p><strong>### 运行环境</strong></p>");
content.append(ttCode.getCodeEnvironment());
content.append("<p><strong>### 项目技术</strong></p>");
content.append("<p><strong>### 其他技术</strong></p>");
content.append(ttCode.getCodeTechnology());
content.append("<p><strong>### 演示视频</strong></p>");
content.append("请移步首页-<strong>视频资源</strong>,搜索<strong>项目编号</strong>查看");

View File

@@ -1,15 +1,30 @@
package com.ruoyi.office.service.impl;
import java.util.Collections;
import java.net.URI;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
import com.ruoyi.office.service.importer.ProjectLinkImportParser;
import com.ruoyi.office.service.netdisk.NetDiskConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.service.ITtProjectInfoService;
@@ -23,12 +38,23 @@ import com.ruoyi.office.service.ITtProjectInfoService;
@Service
public class TtProjectInfoServiceImpl implements ITtProjectInfoService
{
private static final Pattern PROJECT_NUM_PATTERN =
Pattern.compile("^[【\\[]\\s*(S\\d+)\\s*[】\\]]", Pattern.CASE_INSENSITIVE);
private static final Pattern QUARK_URL_PATTERN =
Pattern.compile("https://pan\\.quark\\.cn/s/[A-Za-z0-9_-]+(?:\\?[^\\s\"'<>,。;]*)?");
private static final Pattern BAIDU_PWD_PATTERN =
Pattern.compile("(?:\\?|&)pwd=[^&]*", Pattern.CASE_INSENSITIVE);
@Autowired
private TtProjectInfoMapper ttProjectInfoMapper;
@Autowired
private TtCodeMapper ttCodeMapper;
@Autowired
private TtProjectLinkCheckMapper ttProjectLinkCheckMapper;
@Autowired
private ProjectLinkImportParser projectLinkImportParser;
/**
* 查询项目清单
@@ -129,4 +155,281 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
public TtProjectInfo selectTtProjectInfoByName(String codeName) {
return ttProjectInfoMapper.selectTtProjectInfoByName(codeName);
}
/**
* 按项目名称导入网盘链接。新项目同时写入项目名称和源码名称;
* 已存在项目只更新本次导入的网盘链接字段。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ProjectLinkImportResult importProjectLinks(MultipartFile file, String diskType)
{
String normalizedDiskType = normalizeDiskType(diskType);
List<ProjectLinkImportRow> sourceRows = projectLinkImportParser.parse(file, normalizedDiskType);
ProjectLinkImportResult result = new ProjectLinkImportResult();
result.setTotalCount(sourceRows.size());
List<ValidatedImportRow> validRows = validateRows(sourceRows, normalizedDiskType, result);
Set<String> duplicateNames = findDuplicateNames(validRows);
Map<String, List<TtProjectInfo>> projectsByName = loadProjectsByNormalizedName();
for (ValidatedImportRow row : validRows)
{
if (duplicateNames.contains(row.normalizedName))
{
result.addFailed(row.rowNumber, row.projectName, "导入文件中存在重复项目名称");
continue;
}
List<TtProjectInfo> matches = projectsByName.get(row.normalizedName);
if (matches != null && matches.size() > 1)
{
result.addFailed(row.rowNumber, row.projectName, "数据库中存在多个同名项目,无法确定更新目标");
continue;
}
if (matches == null || matches.isEmpty())
{
TtProjectInfo project = createProject(row, normalizedDiskType);
if (ttProjectInfoMapper.insertTtProjectInfo(project) != 1)
{
throw new ServiceException("新增项目失败:" + row.projectName);
}
List<TtProjectInfo> inserted = new ArrayList<>();
inserted.add(project);
projectsByName.put(row.normalizedName, inserted);
result.incrementAddedCount();
continue;
}
TtProjectInfo project = matches.get(0);
String oldLink = NetDiskConstants.DISK_QUARK.equals(normalizedDiskType)
? project.getProjectUrl() : project.getProjectBaiduUrl();
if (StringUtils.equals(StringUtils.trimToEmpty(oldLink), row.linkUrl))
{
result.incrementUnchangedCount();
continue;
}
int updated;
if (NetDiskConstants.DISK_QUARK.equals(normalizedDiskType))
{
updated = ttProjectInfoMapper.updateProjectQuarkUrl(project.getId(), row.linkUrl);
project.setProjectUrl(row.linkUrl);
}
else
{
updated = ttProjectInfoMapper.updateProjectBaiduUrl(project.getId(), row.linkUrl);
project.setProjectBaiduUrl(row.linkUrl);
}
if (updated != 1)
{
throw new ServiceException("更新项目链接失败:" + row.projectName);
}
ttProjectLinkCheckMapper.deleteByProjectIdAndDiskType(project.getId(), normalizedDiskType);
result.incrementUpdatedCount();
}
return result;
}
private List<ValidatedImportRow> validateRows(List<ProjectLinkImportRow> sourceRows,
String diskType,
ProjectLinkImportResult result)
{
List<ValidatedImportRow> validRows = new ArrayList<>();
String successStatus = NetDiskConstants.DISK_QUARK.equals(diskType) ? "成功" : "生成成功";
for (ProjectLinkImportRow sourceRow : sourceRows)
{
String projectName = normalizeProjectName(sourceRow.getProjectName());
String status = StringUtils.trimToEmpty(sourceRow.getShareStatus());
if (!successStatus.equals(status))
{
result.addSkipped(sourceRow.getRowNumber(), projectName,
"分享状态不是" + successStatus + "" + StringUtils.defaultIfBlank(status, ""));
continue;
}
if (StringUtils.isBlank(projectName))
{
result.addFailed(sourceRow.getRowNumber(), "", "项目名称不能为空");
continue;
}
if (projectName.length() > 255)
{
result.addFailed(sourceRow.getRowNumber(), projectName, "项目名称超过 255 个字符");
continue;
}
try
{
String linkUrl = buildAndValidateLink(sourceRow, diskType);
validRows.add(new ValidatedImportRow(sourceRow.getRowNumber(), projectName,
normalizeProjectName(projectName), linkUrl));
}
catch (IllegalArgumentException e)
{
result.addFailed(sourceRow.getRowNumber(), projectName, e.getMessage());
}
}
return validRows;
}
private String buildAndValidateLink(ProjectLinkImportRow row, String diskType)
{
String linkUrl;
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
Matcher matcher = QUARK_URL_PATTERN.matcher(StringUtils.defaultString(row.getShareAddress()));
if (!matcher.find())
{
throw new IllegalArgumentException("未找到有效的夸克网盘链接");
}
linkUrl = matcher.group();
}
else
{
linkUrl = StringUtils.trimToEmpty(row.getShareAddress());
String extractCode = StringUtils.trimToEmpty(row.getExtractCode());
if (StringUtils.isNotBlank(extractCode) && !BAIDU_PWD_PATTERN.matcher(linkUrl).find())
{
try
{
linkUrl += linkUrl.contains("?") ? "&" : "?";
linkUrl += "pwd=" + URLEncoder.encode(extractCode, "UTF-8");
}
catch (Exception e)
{
throw new IllegalArgumentException("百度网盘提取码处理失败");
}
}
}
if (linkUrl.length() > 1000)
{
throw new IllegalArgumentException("网盘链接超过 1000 个字符");
}
validateLinkHostAndPath(linkUrl, diskType);
return linkUrl;
}
private void validateLinkHostAndPath(String linkUrl, String diskType)
{
try
{
URI uri = new URI(linkUrl);
String host = uri.getHost();
String path = uri.getPath();
boolean isHttps = "https".equalsIgnoreCase(uri.getScheme());
boolean isQuark = NetDiskConstants.DISK_QUARK.equals(diskType)
&& "pan.quark.cn".equalsIgnoreCase(host)
&& path != null && path.startsWith("/s/");
boolean isBaidu = NetDiskConstants.DISK_BAIDU.equals(diskType)
&& host != null
&& ("pan.baidu.com".equalsIgnoreCase(host) || "yun.baidu.com".equalsIgnoreCase(host))
&& path != null
&& (path.startsWith("/s/") || path.startsWith("/share/"));
if (!isHttps || uri.getUserInfo() != null || (uri.getPort() != -1 && uri.getPort() != 443)
|| (!isQuark && !isBaidu))
{
throw new IllegalArgumentException("网盘链接格式不正确");
}
}
catch (IllegalArgumentException e)
{
throw e;
}
catch (Exception e)
{
throw new IllegalArgumentException("网盘链接格式不正确");
}
}
private Map<String, List<TtProjectInfo>> loadProjectsByNormalizedName()
{
Map<String, List<TtProjectInfo>> projectsByName = new HashMap<>();
List<TtProjectInfo> projects = ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo());
for (TtProjectInfo project : projects)
{
String normalizedName = normalizeProjectName(project.getProjectName());
if (StringUtils.isBlank(normalizedName))
{
continue;
}
projectsByName.computeIfAbsent(normalizedName, key -> new ArrayList<>()).add(project);
}
return projectsByName;
}
private Set<String> findDuplicateNames(List<ValidatedImportRow> rows)
{
Set<String> names = new HashSet<>();
Set<String> duplicateNames = new HashSet<>();
for (ValidatedImportRow row : rows)
{
if (!names.add(row.normalizedName))
{
duplicateNames.add(row.normalizedName);
}
}
return duplicateNames;
}
private TtProjectInfo createProject(ValidatedImportRow row, String diskType)
{
TtProjectInfo project = new TtProjectInfo();
project.setProjectName(row.projectName);
project.setProjectName1(row.projectName);
project.setProjectNum(extractProjectNum(row.projectName));
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
project.setProjectUrl(row.linkUrl);
}
else
{
project.setProjectBaiduUrl(row.linkUrl);
}
return project;
}
private String extractProjectNum(String projectName)
{
Matcher matcher = PROJECT_NUM_PATTERN.matcher(projectName);
return matcher.find() ? matcher.group(1).toUpperCase(Locale.ROOT) : null;
}
private String normalizeProjectName(String projectName)
{
return StringUtils.trimToEmpty(projectName)
.replace("\uFEFF", "")
.replace('\u00A0', ' ')
.trim();
}
private String normalizeDiskType(String diskType)
{
String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT);
if (!NetDiskConstants.DISK_QUARK.equals(value) && !NetDiskConstants.DISK_BAIDU.equals(value))
{
throw new ServiceException("不支持的网盘类型");
}
return value;
}
private static class ValidatedImportRow
{
private final int rowNumber;
private final String projectName;
private final String normalizedName;
private final String linkUrl;
private ValidatedImportRow(int rowNumber, String projectName, String normalizedName, String linkUrl)
{
this.rowNumber = rowNumber;
this.projectName = projectName;
this.normalizedName = normalizedName;
this.linkUrl = linkUrl;
}
}
}

View File

@@ -0,0 +1,287 @@
package com.ruoyi.office.service.importer;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import cn.hutool.core.text.csv.CsvData;
import cn.hutool.core.text.csv.CsvReadConfig;
import cn.hutool.core.text.csv.CsvRow;
import cn.hutool.core.text.csv.CsvUtil;
import cn.hutool.poi.excel.ExcelReader;
import cn.hutool.poi.excel.ExcelUtil;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import com.ruoyi.office.service.netdisk.NetDiskConstants;
import org.apache.commons.io.FilenameUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* 解析夸克、百度网盘客户端导出的 CSV 或 Excel 文件。
*/
@Component
public class ProjectLinkImportParser
{
private static final long MAX_FILE_SIZE = 5L * 1024L * 1024L;
private static final int MAX_ROW_COUNT = 2000;
private static final Charset GB18030 = Charset.forName("GB18030");
public List<ProjectLinkImportRow> parse(MultipartFile file, String diskType)
{
validateFile(file);
String normalizedDiskType = normalizeDiskType(diskType);
String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(Locale.ROOT);
try
{
byte[] bytes = file.getBytes();
List<SourceRow> sourceRows;
if ("csv".equals(extension))
{
sourceRows = readCsv(bytes);
}
else if ("xls".equals(extension) || "xlsx".equals(extension))
{
sourceRows = readExcel(bytes);
}
else
{
throw new ServiceException("仅支持 csv、xls、xlsx 格式文件");
}
if (sourceRows.size() > MAX_ROW_COUNT)
{
throw new ServiceException("单次最多导入 " + MAX_ROW_COUNT + " 条数据");
}
validateHeaders(sourceRows, normalizedDiskType);
return convertRows(sourceRows, normalizedDiskType);
}
catch (ServiceException e)
{
throw e;
}
catch (Exception e)
{
throw new ServiceException("文件解析失败,请确认文件格式与网盘类型是否正确");
}
}
private void validateFile(MultipartFile file)
{
if (file == null || file.isEmpty())
{
throw new ServiceException("请选择需要导入的文件");
}
if (file.getSize() > MAX_FILE_SIZE)
{
throw new ServiceException("导入文件不能超过 5MB");
}
if (StringUtils.isBlank(file.getOriginalFilename()))
{
throw new ServiceException("无法识别导入文件名");
}
}
private String normalizeDiskType(String diskType)
{
String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT);
if (!NetDiskConstants.DISK_QUARK.equals(value) && !NetDiskConstants.DISK_BAIDU.equals(value))
{
throw new ServiceException("不支持的网盘类型");
}
return value;
}
private List<SourceRow> readCsv(byte[] bytes)
{
String csvText = decodeCsv(bytes);
CsvReadConfig config = CsvReadConfig.defaultConfig()
.setContainsHeader(true)
.setSkipEmptyRows(true)
.setErrorOnDifferentFieldCount(true);
CsvData csvData = CsvUtil.getReader(new StringReader(csvText), config).read();
List<SourceRow> rows = new ArrayList<>();
for (CsvRow csvRow : csvData.getRows())
{
rows.add(new SourceRow((int) csvRow.getOriginalLineNumber() + 1,
normalizeFieldMap(csvRow.getFieldMap())));
}
if (rows.isEmpty())
{
rows.add(new SourceRow(1, headerOnlyMap(csvData.getHeader())));
}
return rows;
}
private List<SourceRow> readExcel(byte[] bytes)
{
List<SourceRow> rows = new ArrayList<>();
try (ExcelReader reader = ExcelUtil.getReader(new ByteArrayInputStream(bytes)))
{
List<Map<String, Object>> excelRows = reader.readAll();
int rowNumber = 2;
for (Map<String, Object> excelRow : excelRows)
{
Map<String, String> fields = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : excelRow.entrySet())
{
fields.put(normalizeHeader(entry.getKey()), objectToString(entry.getValue()));
}
rows.add(new SourceRow(rowNumber++, fields));
}
if (rows.isEmpty())
{
List<Object> headers = reader.readRow(0);
Map<String, String> headerMap = new LinkedHashMap<>();
for (Object header : headers)
{
headerMap.put(normalizeHeader(objectToString(header)), "");
}
rows.add(new SourceRow(1, headerMap));
}
}
return rows;
}
private String decodeCsv(byte[] bytes)
{
try
{
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
CharBuffer decoded = decoder.decode(ByteBuffer.wrap(bytes));
return decoded.toString();
}
catch (CharacterCodingException ignored)
{
return new String(bytes, GB18030);
}
}
private Map<String, String> normalizeFieldMap(Map<String, String> source)
{
Map<String, String> fields = new LinkedHashMap<>();
for (Map.Entry<String, String> entry : source.entrySet())
{
fields.put(normalizeHeader(entry.getKey()), entry.getValue());
}
return fields;
}
private Map<String, String> headerOnlyMap(List<String> headers)
{
Map<String, String> fields = new LinkedHashMap<>();
if (headers != null)
{
for (String header : headers)
{
fields.put(normalizeHeader(header), "");
}
}
return fields;
}
private void validateHeaders(List<SourceRow> rows, String diskType)
{
Map<String, String> fields = rows.get(0).fields;
List<String> requiredHeaders = new ArrayList<>();
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
requiredHeaders.add("创建分享状态");
requiredHeaders.add("分享名");
requiredHeaders.add("分享地址");
}
else
{
requiredHeaders.add("文件名");
requiredHeaders.add("链接");
requiredHeaders.add("分享状态");
}
for (String header : requiredHeaders)
{
if (!fields.containsKey(header))
{
throw new ServiceException("文件缺少必要列:" + header);
}
}
}
private List<ProjectLinkImportRow> convertRows(List<SourceRow> sourceRows, String diskType)
{
List<ProjectLinkImportRow> rows = new ArrayList<>();
for (SourceRow sourceRow : sourceRows)
{
if (sourceRow.rowNumber == 1 && allValuesBlank(sourceRow.fields))
{
continue;
}
ProjectLinkImportRow row = new ProjectLinkImportRow();
row.setRowNumber(sourceRow.rowNumber);
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
row.setProjectName(sourceRow.fields.get("分享名"));
row.setShareAddress(sourceRow.fields.get("分享地址"));
row.setExtractCode(sourceRow.fields.get("提取码"));
row.setShareStatus(sourceRow.fields.get("创建分享状态"));
}
else
{
row.setProjectName(sourceRow.fields.get("文件名"));
row.setShareAddress(sourceRow.fields.get("链接"));
row.setExtractCode(sourceRow.fields.get("提取码"));
row.setShareStatus(sourceRow.fields.get("分享状态"));
}
rows.add(row);
}
return rows;
}
private boolean allValuesBlank(Map<String, String> fields)
{
for (String value : fields.values())
{
if (StringUtils.isNotBlank(value))
{
return false;
}
}
return true;
}
private String normalizeHeader(String header)
{
return StringUtils.trimToEmpty(header).replace("\uFEFF", "");
}
private String objectToString(Object value)
{
return value == null ? "" : String.valueOf(value);
}
private static class SourceRow
{
private final int rowNumber;
private final Map<String, String> fields;
private SourceRow(int rowNumber, Map<String, String> fields)
{
this.rowNumber = rowNumber;
this.fields = fields;
}
}
}

View File

@@ -10,6 +10,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="codeDesc" column="code_desc" />
<result property="codeEnvironment" column="code_environment" />
<result property="codeTechnology" column="code_technology" />
<result property="frontendTechnology" column="frontend_technology" />
<result property="backendTechnology" column="backend_technology" />
<result property="databaseTechnology" column="database_technology" />
<result property="codeSource" column="code_source" />
<result property="paymentType" column="payment_type" />
<result property="diskLink" column="disk_link" />
@@ -19,7 +22,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectTtCodeVo">
select code_id, code_name, code_desc, code_environment, code_technology, code_source, payment_type, disk_link, publish_flag, picture_file, video_file from tt_code
select code_id, code_name, code_desc, code_environment, code_technology,
frontend_technology, backend_technology, database_technology,
code_source, payment_type, disk_link, publish_flag, picture_file, video_file
from tt_code
</sql>
<select id="selectTtCodeList" parameterType="TtCode" resultMap="TtCodeResult">
@@ -29,6 +35,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null and codeDesc != ''"> and code_desc = #{codeDesc}</if>
<if test="codeEnvironment != null and codeEnvironment != ''"> and code_environment = #{codeEnvironment}</if>
<if test="codeTechnology != null and codeTechnology != ''"> and code_technology = #{codeTechnology}</if>
<if test="frontendTechnology != null and frontendTechnology != ''"> and frontend_technology = #{frontendTechnology}</if>
<if test="backendTechnology != null and backendTechnology != ''"> and backend_technology = #{backendTechnology}</if>
<if test="databaseTechnology != null and databaseTechnology != ''"> and database_technology = #{databaseTechnology}</if>
<if test="codeSource != null and codeSource != ''"> and code_source = #{codeSource}</if>
<if test="paymentType != null and paymentType != ''"> and payment_type = #{paymentType}</if>
<if test="diskLink != null and diskLink != ''"> and disk_link = #{diskLink}</if>
@@ -57,6 +66,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null">code_desc,</if>
<if test="codeEnvironment != null">code_environment,</if>
<if test="codeTechnology != null">code_technology,</if>
<if test="frontendTechnology != null">frontend_technology,</if>
<if test="backendTechnology != null">backend_technology,</if>
<if test="databaseTechnology != null">database_technology,</if>
<if test="codeSource != null">code_source,</if>
<if test="paymentType != null">payment_type,</if>
<if test="diskLink != null">disk_link,</if>
@@ -70,6 +82,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null">#{codeDesc},</if>
<if test="codeEnvironment != null">#{codeEnvironment},</if>
<if test="codeTechnology != null">#{codeTechnology},</if>
<if test="frontendTechnology != null">#{frontendTechnology},</if>
<if test="backendTechnology != null">#{backendTechnology},</if>
<if test="databaseTechnology != null">#{databaseTechnology},</if>
<if test="codeSource != null">#{codeSource},</if>
<if test="paymentType != null">#{paymentType},</if>
<if test="diskLink != null">#{diskLink},</if>
@@ -86,6 +101,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null">code_desc = #{codeDesc},</if>
<if test="codeEnvironment != null">code_environment = #{codeEnvironment},</if>
<if test="codeTechnology != null">code_technology = #{codeTechnology},</if>
<if test="frontendTechnology != null">frontend_technology = #{frontendTechnology},</if>
<if test="backendTechnology != null">backend_technology = #{backendTechnology},</if>
<if test="databaseTechnology != null">database_technology = #{databaseTechnology},</if>
<if test="codeSource != null">code_source = #{codeSource},</if>
<if test="paymentType != null">payment_type = #{paymentType},</if>
<if test="diskLink != null">disk_link = #{diskLink},</if>

View File

@@ -113,6 +113,18 @@
where id = #{id}
</update>
<update id="updateProjectQuarkUrl">
update tt_project_info
set project_url = #{projectUrl}
where id = #{id}
</update>
<update id="updateProjectBaiduUrl">
update tt_project_info
set project_baidu_url = #{projectBaiduUrl}
where id = #{id}
</update>
<delete id="deleteTtProjectInfoById" parameterType="Integer">
delete from tt_project_info where id = #{id}
</delete>

View File

@@ -52,4 +52,10 @@
#{projectId}
</foreach>
</delete>
<delete id="deleteByProjectIdAndDiskType">
delete from tt_project_link_check
where project_id = #{projectId}
and disk_type = #{diskType}
</delete>
</mapper>

View File

@@ -0,0 +1,112 @@
package com.ruoyi.office.service.impl;
import java.util.Collections;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
import com.ruoyi.office.service.importer.ProjectLinkImportParser;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.web.multipart.MultipartFile;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class TtProjectInfoServiceImplTest
{
@InjectMocks
private TtProjectInfoServiceImpl service;
@Mock
private TtProjectInfoMapper projectInfoMapper;
@Mock
private TtCodeMapper codeMapper;
@Mock
private TtProjectLinkCheckMapper linkCheckMapper;
@Mock
private ProjectLinkImportParser parser;
@Mock
private MultipartFile file;
private ProjectLinkImportRow quarkRow;
@Before
public void setUp()
{
quarkRow = new ProjectLinkImportRow();
quarkRow.setRowNumber(2);
quarkRow.setProjectName("【S031】婚纱摄影管理系统");
quarkRow.setShareAddress("分享内容\r\n链接https://pan.quark.cn/s/741bf63df1bb");
quarkRow.setShareStatus("成功");
}
@Test
public void createsMissingProjectWithBothNameFields()
{
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.emptyList());
when(projectInfoMapper.insertTtProjectInfo(any(TtProjectInfo.class))).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
ArgumentCaptor<TtProjectInfo> captor = ArgumentCaptor.forClass(TtProjectInfo.class);
verify(projectInfoMapper).insertTtProjectInfo(captor.capture());
TtProjectInfo inserted = captor.getValue();
Assert.assertEquals("【S031】婚纱摄影管理系统", inserted.getProjectName());
Assert.assertEquals("【S031】婚纱摄影管理系统", inserted.getProjectName1());
Assert.assertEquals("S031", inserted.getProjectNum());
Assert.assertEquals("https://pan.quark.cn/s/741bf63df1bb", inserted.getProjectUrl());
Assert.assertEquals(1, result.getAddedCount());
}
@Test
public void updatesOnlyBaiduLinkForExistingProjectAndAddsPasscode()
{
ProjectLinkImportRow baiduRow = new ProjectLinkImportRow();
baiduRow.setRowNumber(2);
baiduRow.setProjectName("【S031】婚纱摄影管理系统");
baiduRow.setShareAddress("https://pan.baidu.com/s/1abc");
baiduRow.setExtractCode("iuxg");
baiduRow.setShareStatus("生成成功");
TtProjectInfo existing = new TtProjectInfo();
existing.setId(31);
existing.setProjectName("【S031】婚纱摄影管理系统");
existing.setProjectName1("原源码名称");
existing.setProjectUrl("https://pan.quark.cn/s/old");
when(parser.parse(file, "BAIDU")).thenReturn(Collections.singletonList(baiduRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.singletonList(existing));
when(projectInfoMapper.updateProjectBaiduUrl(31,
"https://pan.baidu.com/s/1abc?pwd=iuxg")).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "BAIDU");
verify(projectInfoMapper).updateProjectBaiduUrl(31,
"https://pan.baidu.com/s/1abc?pwd=iuxg");
verify(linkCheckMapper).deleteByProjectIdAndDiskType(31, "BAIDU");
verify(projectInfoMapper, never()).updateTtProjectInfo(any(TtProjectInfo.class));
verify(projectInfoMapper, never()).insertTtProjectInfo(any(TtProjectInfo.class));
Assert.assertEquals("原源码名称", existing.getProjectName1());
Assert.assertEquals(1, result.getUpdatedCount());
}
}

View File

@@ -0,0 +1,89 @@
package com.ruoyi.office.service.importer;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.mock.web.MockMultipartFile;
public class ProjectLinkImportParserTest
{
private final ProjectLinkImportParser parser = new ProjectLinkImportParser();
@Test
public void parsesQuarkCsvWithMultilineShareAddress()
{
String csv = "创建分享状态,分享名,分享地址,提取码,分享时间\r\n"
+ "成功,【S031】婚纱摄影管理系统,\"我用夸克网盘分享了文件。\r\n"
+ "链接https://pan.quark.cn/s/741bf63df1bb\",,2026-07-29 12:25\r\n";
MockMultipartFile file = new MockMultipartFile("file", "quark.csv", "text/csv",
csv.getBytes(StandardCharsets.UTF_8));
List<ProjectLinkImportRow> rows = parser.parse(file, "QUARK");
Assert.assertEquals(1, rows.size());
Assert.assertEquals(2, rows.get(0).getRowNumber());
Assert.assertEquals("【S031】婚纱摄影管理系统", rows.get(0).getProjectName());
Assert.assertTrue(rows.get(0).getShareAddress().contains("https://pan.quark.cn/s/741bf63df1bb"));
Assert.assertEquals("成功", rows.get(0).getShareStatus());
}
@Test
public void parsesBaiduCsv()
{
String csv = "文件名,链接,提取码,分享时间,分享状态\r\n"
+ "【S001】家政服务人员技能评级系统,"
+ "https://pan.baidu.com/s/1EimsC2N8zzvrCP6mn3fNyQ,iuxg,"
+ "2026-07-29 12:35,生成成功\r\n";
MockMultipartFile file = new MockMultipartFile("file", "baidu.csv", "text/csv",
csv.getBytes(StandardCharsets.UTF_8));
List<ProjectLinkImportRow> rows = parser.parse(file, "BAIDU");
Assert.assertEquals(1, rows.size());
Assert.assertEquals(2, rows.get(0).getRowNumber());
Assert.assertEquals("【S001】家政服务人员技能评级系统", rows.get(0).getProjectName());
Assert.assertEquals("iuxg", rows.get(0).getExtractCode());
Assert.assertEquals("生成成功", rows.get(0).getShareStatus());
}
@Test
public void parsesBaiduExcel() throws Exception
{
byte[] excelBytes;
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream())
{
Sheet sheet = workbook.createSheet("分享记录");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("文件名");
header.createCell(1).setCellValue("链接");
header.createCell(2).setCellValue("提取码");
header.createCell(3).setCellValue("分享时间");
header.createCell(4).setCellValue("分享状态");
Row data = sheet.createRow(1);
data.createCell(0).setCellValue("【S001】家政服务人员技能评级系统");
data.createCell(1).setCellValue("https://pan.baidu.com/s/1abc");
data.createCell(2).setCellValue("iuxg");
data.createCell(3).setCellValue("2026-07-29 12:35");
data.createCell(4).setCellValue("生成成功");
workbook.write(output);
excelBytes = output.toByteArray();
}
MockMultipartFile file = new MockMultipartFile("file", "baidu.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", excelBytes);
List<ProjectLinkImportRow> rows = parser.parse(file, "BAIDU");
Assert.assertEquals(1, rows.size());
Assert.assertEquals("【S001】家政服务人员技能评级系统", rows.get(0).getProjectName());
Assert.assertEquals("生成成功", rows.get(0).getShareStatus());
}
}