Expand publishing and virtual product workflows

This commit is contained in:
王鹏
2026-09-30 11:00:01 +08:00
parent 4ed8743934
commit b904dcf52e
54 changed files with 2185 additions and 108 deletions

View File

@@ -20,6 +20,7 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtCopyTemplateGroupStat;
import com.ruoyi.office.service.CopyTemplateRenderer;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.ITtCopyTemplateService;
@@ -56,6 +57,16 @@ public class TtCopyTemplateController extends BaseController {
return getDataTable(list);
}
/**
* 查询模板分组及各分组模板数量
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:list')")
@GetMapping("/groups")
public AjaxResult groups() {
List<TtCopyTemplateGroupStat> groups = ttCopyTemplateService.selectCopyTemplateGroupStats();
return success(groups);
}
/**
* 按场景查询所有启用的文案模板(无需分页权限)
*/

View File

@@ -0,0 +1,37 @@
package com.ruoyi.office.domain;
import java.io.Serializable;
/**
* 文案模板分组统计。
*/
public class TtCopyTemplateGroupStat implements Serializable
{
private static final long serialVersionUID = 1L;
/** 分组名称;__UNGROUPED__ 表示未分组。 */
private String templateGroup;
/** 分组下的模板数量。 */
private Long templateCount;
public String getTemplateGroup()
{
return templateGroup;
}
public void setTemplateGroup(String templateGroup)
{
this.templateGroup = templateGroup;
}
public Long getTemplateCount()
{
return templateCount;
}
public void setTemplateCount(Long templateCount)
{
this.templateCount = templateCount;
}
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtCopyTemplateGroupStat;
import org.apache.ibatis.annotations.Param;
/**
@@ -22,6 +23,11 @@ public interface TtCopyTemplateMapper {
*/
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
/**
* 查询模板分组及模板数量
*/
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats();
/**
* 查询所有启用的文案模板(按排序号升序)
*/

View File

@@ -2,6 +2,7 @@ package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtCopyTemplateGroupStat;
/**
* 文案模板Service接口
@@ -21,6 +22,11 @@ public interface ITtCopyTemplateService {
*/
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
/**
* 查询模板分组及模板数量
*/
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats();
/**
* 查询所有启用的文案模板(供源码明细页按钮使用)
*/

View File

@@ -19,6 +19,16 @@ public class WebsiteArticleDocumentParser
private static final Pattern MARKDOWN_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
public WebsiteArticleDocument parse(String renderedContent)
{
return parse(renderedContent, true);
}
public WebsiteArticleDocument parseForWechat(String renderedContent)
{
return parse(renderedContent, false);
}
private WebsiteArticleDocument parse(String renderedContent, boolean enforceWebsiteTitleLimit)
{
if (StringUtils.isBlank(renderedContent))
{
@@ -44,7 +54,7 @@ public class WebsiteArticleDocumentParser
{
throw new ServiceException("复制网站文案第一行标题为空");
}
if (title.length() > 50)
if (enforceWebsiteTitleLimit && title.length() > 50)
{
throw new ServiceException("网站文章标题不能超过50个字符:" + title);
}

View File

@@ -50,7 +50,7 @@ public class WechatDraftContentService
if (code == null) throw new ServiceException("源码项目不存在");
if (template == null || !"0".equals(template.getStatus())) throw new ServiceException("文案模板不存在或已停用");
WebsiteArticleDocument generated = documentParser.parse(templateRenderer.render(template, code));
WebsiteArticleDocument generated = documentParser.parseForWechat(templateRenderer.render(template, code));
String markdown = StringUtils.isNotBlank(request.getContentMarkdown())
? request.getContentMarkdown() : generated.getContentMarkdown();
String title = StringUtils.isNotBlank(request.getTitle()) ? request.getTitle() : generated.getTitle();
@@ -59,7 +59,7 @@ public class WechatDraftContentService
WechatDraftPreview preview = new WechatDraftPreview();
preview.setRequestNo(StringUtils.isNotBlank(request.getRequestNo())
? request.getRequestNo() : UUID.randomUUID().toString().replace("-", ""));
preview.setTitle(limit(title, 32));
preview.setTitle(limit(title, 60));
preview.setAuthor(limit(firstNonBlank(request.getAuthor(), account == null ? null : account.getDefaultAuthor()), 16));
String html = articleRenderer.render(markdown, request.getThemeColor(), request.getThemeName(), request.getFontSize());
// 摘要默认留空,交由微信使用正文内容生成展示摘要;手动摘要遵循微信 120 字限制。

View File

@@ -38,13 +38,14 @@ import com.ruoyi.office.service.ITtCodeService;
@Service
public class TtCodeServiceImpl implements ITtCodeService {
private static final Pattern PREMIUM_PROJECT_CODE_PATTERN =
Pattern.compile("(?i)(?:^|【)\\s*([SK])\\s*\\d+");
Pattern.compile("(?i)(?:^|【)\\s*([SKF])\\s*\\d+");
private static final long SOURCE_SHARE_RESOURCE_TYPE = 5L;
private static final long PREMIUM_PROJECT_RESOURCE_TYPE = 6L;
private static final long SOURCE_SHARE_ARTICLE_TYPE = 4L;
private static final long PREMIUM_SOURCE_ARTICLE_TYPE = 7L;
private static final long POINTS_ACCESS_TYPE = 2L;
private static final long PAID_ACCESS_TYPE = 3L;
private static final String F_BASIC_SPEC_NAME = "源码 + 数据库 + 万字文档 + PPT";
private static final String BASIC_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT";
private static final String DEPLOYMENT_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT + 项目部署";
private static final String DEPLOYMENT_CONTACT = "调试部署加微信:forfeastcoding";
@@ -286,6 +287,12 @@ public class TtCodeServiceImpl implements ITtCodeService {
String projectSeries)
{
List<AppResourceList> resourceList = new ArrayList<AppResourceList>();
if ("F".equals(projectSeries))
{
addResourceList(resourceList, resourceId, F_BASIC_SPEC_NAME,
projectInfo.getProjectUrl(), 900, 1);
return resourceList;
}
if ("K".equals(projectSeries))
{
addResourceList(resourceList, resourceId, BASIC_SPEC_NAME,
@@ -346,7 +353,7 @@ public class TtCodeServiceImpl implements ITtCodeService {
resource.setResourceType(PREMIUM_PROJECT_RESOURCE_TYPE);
resource.setIsAd(PAID_ACCESS_TYPE);
resource.setAdNumber(0L);
resource.setPriceFen("K".equals(projectSeries) ? 1900 : 5900);
resource.setPriceFen(resolvePremiumBasePrice(projectSeries));
return;
}
resource.setResourceType(SOURCE_SHARE_RESOURCE_TYPE);
@@ -357,7 +364,17 @@ public class TtCodeServiceImpl implements ITtCodeService {
private boolean isPremiumProject(String projectSeries)
{
return "S".equals(projectSeries) || "K".equals(projectSeries);
return "S".equals(projectSeries) || "K".equals(projectSeries)
|| "F".equals(projectSeries);
}
private int resolvePremiumBasePrice(String projectSeries)
{
if ("F".equals(projectSeries))
{
return 900;
}
return "K".equals(projectSeries) ? 1900 : 5900;
}
private String resolveProjectSeries(TtCode code, TtProjectInfo projectInfo)

View File

@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtCopyTemplateGroupStat;
import com.ruoyi.office.mapper.TtCopyTemplateMapper;
import com.ruoyi.office.service.ITtCopyTemplateService;
@@ -30,6 +31,11 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService {
return ttCopyTemplateMapper.selectTtCopyTemplateList(ttCopyTemplate);
}
@Override
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats() {
return ttCopyTemplateMapper.selectCopyTemplateGroupStats();
}
@Override
public List<TtCopyTemplate> selectEnabledTemplates() {
return selectEnabledTemplates(null);

View File

@@ -21,7 +21,7 @@ public class TtFileServiceImpl implements ITtFileService {
@Autowired
private TtFileMapper ttFileMapper;
private final String SITE_URL = "http://img.yidaima.cn/";
private final String SITE_URL = "https://img.yidaima.cn/";
/**
* 查询文件管理
@@ -58,7 +58,7 @@ public class TtFileServiceImpl implements ITtFileService {
String[] pictures = ttFile.getPictureFile().split(",");
TreeMap<Integer, String> treeMap = new TreeMap();
for (String url : pictures) {
url = url.replaceAll(SITE_URL, "");
url = url.replace(SITE_URL, "");
String[] urls = url.split("-");
String picName = urls[0];
if(picName.contains("_")){

View File

@@ -3,11 +3,9 @@ package com.ruoyi.office.service.impl;
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;
@@ -171,7 +169,8 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
List<ValidatedImportRow> validRows = validateRows(sourceRows, normalizedDiskType, result);
Set<String> duplicateNames = findDuplicateNames(validRows);
Map<String, List<TtProjectInfo>> projectsByName = loadProjectsByNormalizedName();
List<TtProjectInfo> projects = new ArrayList<>(
ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo()));
for (ValidatedImportRow row : validRows)
{
@@ -181,23 +180,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
continue;
}
List<TtProjectInfo> matches = projectsByName.get(row.normalizedName);
if (matches != null && matches.size() > 1)
List<TtProjectInfo> matches = findMatchingProjects(row, projects);
if (matches.size() > 1)
{
result.addFailed(row.rowNumber, row.projectName, "数据库中存在多个同名项目,无法确定更新目标");
result.addFailed(row.rowNumber, row.projectName,
"项目编号、项目名称或源码名称匹配到多条数据,无法确定更新目标");
continue;
}
if (matches == null || matches.isEmpty())
if (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);
projects.add(project);
result.incrementAddedCount();
continue;
}
@@ -344,20 +342,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
}
}
private Map<String, List<TtProjectInfo>> loadProjectsByNormalizedName()
private List<TtProjectInfo> findMatchingProjects(ValidatedImportRow row, List<TtProjectInfo> projects)
{
Map<String, List<TtProjectInfo>> projectsByName = new HashMap<>();
List<TtProjectInfo> projects = ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo());
List<TtProjectInfo> matches = new ArrayList<>();
String projectNum = normalizeProjectNum(extractProjectNum(row.projectName));
for (TtProjectInfo project : projects)
{
String normalizedName = normalizeProjectName(project.getProjectName());
if (StringUtils.isBlank(normalizedName))
boolean projectNumMatches = StringUtils.isNotBlank(projectNum)
&& projectNum.equals(normalizeProjectNum(project.getProjectNum()));
boolean projectNameMatches = row.normalizedName.equals(normalizeProjectName(project.getProjectName()));
boolean sourceNameMatches = row.normalizedName.equals(normalizeProjectName(project.getProjectName1()));
if (projectNumMatches || projectNameMatches || sourceNameMatches)
{
continue;
matches.add(project);
}
projectsByName.computeIfAbsent(normalizedName, key -> new ArrayList<>()).add(project);
}
return projectsByName;
return matches;
}
private Set<String> findDuplicateNames(List<ValidatedImportRow> rows)
@@ -419,6 +419,11 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
.trim();
}
private String normalizeProjectNum(String projectNum)
{
return normalizeProjectName(projectNum).toUpperCase(Locale.ROOT);
}
private String normalizeDiskType(String diskType)
{
String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT);

View File

@@ -6,6 +6,8 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.file.Files;
@@ -38,16 +40,21 @@ import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.PublishStatusUpdate;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
import com.ruoyi.office.domain.TtPublishTask;
import com.ruoyi.office.mapper.TtPublishTaskMapper;
import com.ruoyi.office.service.CopyTemplateRenderer;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.ITtCopyTemplateService;
import com.ruoyi.office.service.ITtFileService;
import com.ruoyi.office.service.ITtPublishTaskService;
@Service
public class TtPublishTaskServiceImpl implements ITtPublishTaskService
{
private static final int MAX_PUBLISH_IMAGES = 9;
private static final int MAX_ASSET_REDIRECTS = 5;
private static final Set<String> PLATFORMS = Collections.unmodifiableSet(
new LinkedHashSet<>(Arrays.asList("xiaohongshu", "douyin", "wechat_channels", "bilibili", "xianyu")));
@@ -66,6 +73,9 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
@Autowired
private ITtCopyTemplateService copyTemplateService;
@Autowired
private ITtFileService fileService;
@Autowired
private CopyTemplateRenderer copyTemplateRenderer;
@@ -87,6 +97,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
material.setCodeId(code.getCodeId());
material.setCodeName(code.getCodeName());
material.setTitle(code.getCodeName());
material.setImageUrls(selectProjectImageUrls(code.getCodeName()));
TtCopyTemplate selectedTemplate = selectPublishTemplate(
copyTemplateService.selectEnabledTemplates("CODE"), templateGroup);
if (selectedTemplate != null)
@@ -99,6 +110,29 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
return material;
}
/**
* 直接复用文件管理中与项目关联的图片,避免发布时再次上传同一批素材。
*/
private List<String> selectProjectImageUrls(String codeName)
{
List<TtFile> files = fileService.selectTtFileByCodeName(codeName);
List<String> urls = new ArrayList<>();
if (files != null)
{
for (TtFile file : files)
{
if (file != null)
{
urls.add(file.getFileUrl());
}
}
}
List<String> cleaned = cleanUrls(urls);
return cleaned.size() <= MAX_PUBLISH_IMAGES
? cleaned
: new ArrayList<>(cleaned.subList(0, MAX_PUBLISH_IMAGES));
}
private TtCopyTemplate selectPublishTemplate(List<TtCopyTemplate> templates, String targetGroup)
{
if (templates == null || templates.isEmpty())
@@ -295,6 +329,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
}
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length");
response.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
response.setHeader("Cache-Control", "private, no-store");
@@ -374,22 +409,8 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
private void writeRemoteAsset(String source, HttpServletResponse response) throws IOException
{
URL url = new URL(source);
if (!"http".equalsIgnoreCase(url.getProtocol()) && !"https".equalsIgnoreCase(url.getProtocol()))
{
throw new ServiceException("不支持的发布素材地址");
}
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(10000);
connection.setReadTimeout(120000);
connection.setInstanceFollowRedirects(true);
connection.setRequestProperty("User-Agent", "RuoYi-Publish-Bridge/1.0");
int status = connection.getResponseCode();
if (status < 200 || status >= 300)
{
connection.disconnect();
throw new ServiceException("读取远程发布素材失败,HTTP " + status);
}
HttpURLConnection connection = openRemoteAssetConnection(source);
URL finalUrl = connection.getURL();
String contentType = connection.getContentType();
response.setContentType(contentType == null ? "application/octet-stream" : contentType);
long length = connection.getContentLengthLong();
@@ -397,7 +418,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
{
response.setContentLengthLong(length);
}
String fileName = new File(url.getPath()).getName();
String fileName = new File(finalUrl.getPath()).getName();
response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(fileName));
try (InputStream input = connection.getInputStream())
{
@@ -409,6 +430,74 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService
}
}
/**
* HttpURLConnection 不会自动跟随 HTTP 到 HTTPS 的跨协议重定向,图床地址需要在这里显式处理。
*/
private HttpURLConnection openRemoteAssetConnection(String source) throws IOException
{
URL currentUrl = requireHttpAssetUrl(source);
for (int redirectCount = 0; redirectCount <= MAX_ASSET_REDIRECTS; redirectCount++)
{
HttpURLConnection connection = (HttpURLConnection) currentUrl.openConnection();
connection.setConnectTimeout(10000);
connection.setReadTimeout(120000);
connection.setInstanceFollowRedirects(false);
connection.setRequestProperty("User-Agent", "RuoYi-Publish-Bridge/1.0");
int status = connection.getResponseCode();
if (status >= 200 && status < 300)
{
return connection;
}
if (!isRedirectStatus(status))
{
connection.disconnect();
throw new ServiceException("读取远程发布素材失败,HTTP " + status);
}
String location = connection.getHeaderField("Location");
connection.disconnect();
if (StringUtils.isEmpty(location))
{
throw new ServiceException("远程发布素材重定向地址为空");
}
if (redirectCount == MAX_ASSET_REDIRECTS)
{
throw new ServiceException("远程发布素材重定向次数过多");
}
currentUrl = requireHttpAssetUrl(new URL(currentUrl, location).toString());
}
throw new ServiceException("读取远程发布素材失败");
}
private URL requireHttpAssetUrl(String source) throws IOException
{
final URI uri;
try
{
uri = new URI(source);
}
catch (URISyntaxException ex)
{
throw new IOException("发布素材地址格式错误", ex);
}
String scheme = uri.getScheme();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))
{
throw new ServiceException("不支持的发布素材地址");
}
// URL/HttpURLConnection 不会自动把中文路径转换为 UTF-8 百分号编码。
// 图床会原样重定向乱码路径,最终导致本来存在的图片返回 404。
return new URL(uri.toASCIIString());
}
private boolean isRedirectStatus(int status)
{
return status == HttpURLConnection.HTTP_MOVED_PERM
|| status == HttpURLConnection.HTTP_MOVED_TEMP
|| status == HttpURLConnection.HTTP_SEE_OTHER
|| status == 307
|| status == 308;
}
private void copy(InputStream input, OutputStream output) throws IOException
{
byte[] buffer = new byte[8192];

View File

@@ -16,6 +16,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="updateTime" column="update_time" />
</resultMap>
<resultMap type="TtCopyTemplateGroupStat" id="TtCopyTemplateGroupStatResult">
<result property="templateGroup" column="template_group" />
<result property="templateCount" column="template_count" />
</resultMap>
<sql id="selectTtCopyTemplateVo">
select template_id, template_name, template_group, template_scene, template_body, sort_num, status, create_time, update_time
from tt_copy_template
@@ -25,13 +30,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectTtCopyTemplateVo"/>
<where>
<if test="templateName != null and templateName != ''"> and template_name like concat('%', #{templateName}, '%')</if>
<if test="templateGroup != null and templateGroup != ''"> and template_group like concat('%', #{templateGroup}, '%')</if>
<if test="templateGroup != null and templateGroup != ''">
<choose>
<when test="templateGroup == '__UNGROUPED__'">
and (template_group is null or trim(template_group) = '')
</when>
<otherwise>
and template_group = #{templateGroup}
</otherwise>
</choose>
</if>
<if test="templateScene != null and templateScene != ''"> and template_scene = #{templateScene}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by sort_num asc, create_time asc
</select>
<select id="selectCopyTemplateGroupStats" resultMap="TtCopyTemplateGroupStatResult">
select
case
when template_group is null or trim(template_group) = '' then '__UNGROUPED__'
else template_group
end as template_group,
count(1) as template_count
from tt_copy_template
group by
case
when template_group is null or trim(template_group) = '' then '__UNGROUPED__'
else template_group
end
order by min(sort_num) asc, template_group asc
</select>
<select id="selectEnabledTemplates" resultMap="TtCopyTemplateResult">
<include refid="selectTtCopyTemplateVo"/>
where status = '0'

View File

@@ -30,4 +30,17 @@ public class WebsiteArticleDocumentParserTest
{
parser.parse("只有标题");
}
@Test(expected = ServiceException.class)
public void keepsWebsiteTitleLimitAtFiftyCharacters()
{
parser.parse(repeat("题", 51) + "\n正文");
}
private String repeat(String value, int count)
{
StringBuilder result = new StringBuilder();
for (int i = 0; i < count; i++) result.append(value);
return result.toString();
}
}

View File

@@ -0,0 +1,51 @@
package com.ruoyi.office.service;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.WechatDraftPreview;
import com.ruoyi.office.domain.WechatDraftPushRequest;
import org.junit.Assert;
import org.junit.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class WechatDraftContentServiceTest
{
@Test
public void acceptsSixtyCharacterTitleAndLimitsGeneratedTitle()
{
ITtCodeService codeService = mock(ITtCodeService.class);
ITtCopyTemplateService templateService = mock(ITtCopyTemplateService.class);
ITtWechatAccountService accountService = mock(ITtWechatAccountService.class);
CopyTemplateRenderer templateRenderer = mock(CopyTemplateRenderer.class);
WechatArticleRenderer articleRenderer = mock(WechatArticleRenderer.class);
WechatDraftContentService service = new WechatDraftContentService(codeService, templateService,
accountService, templateRenderer, new WebsiteArticleDocumentParser(), articleRenderer);
TtCode code = new TtCode();
TtCopyTemplate template = new TtCopyTemplate();
template.setStatus("0");
when(codeService.selectTtCodeByCodeId(1L)).thenReturn(code);
when(templateService.selectTtCopyTemplateByTemplateId(2L)).thenReturn(template);
when(templateRenderer.render(template, code)).thenReturn(repeat("题", 61) + "\n正文");
WechatDraftPushRequest request = new WechatDraftPushRequest();
request.setCodeId(1L);
request.setTemplateId(2L);
request.setTitle(repeat("字", 60));
WechatDraftPreview preview = service.preview(request);
Assert.assertEquals(repeat("字", 60), preview.getTitle());
request.setTitle("");
preview = service.preview(request);
Assert.assertEquals(repeat("题", 60), preview.getTitle());
}
private String repeat(String value, int count)
{
StringBuilder result = new StringBuilder();
for (int i = 0; i < count; i++) result.append(value);
return result.toString();
}
}

View File

@@ -145,6 +145,36 @@ public class TtCodeServiceImplTest
Assert.assertEquals(Long.valueOf(7L), articleCaptor.getValue().getArticleType());
}
@Test
public void createsFPremiumProjectWithSingleNineYuanSpec()
{
code.setCodeName("【F001】毕业设计项目");
projectInfo.setProjectNum(" f001 ");
when(resourceService.insertAppResource(any())).thenAnswer(invocation -> {
AppResource resource = invocation.getArgument(0);
resource.setId(103L);
return 1;
});
service.transToArticle1(9L, 1L);
ArgumentCaptor<AppResource> resourceCaptor = ArgumentCaptor.forClass(AppResource.class);
verify(resourceService).insertAppResource(resourceCaptor.capture());
AppResource resource = resourceCaptor.getValue();
Assert.assertEquals(Long.valueOf(6L), resource.getResourceType());
Assert.assertEquals(Long.valueOf(3L), resource.getIsAd());
Assert.assertEquals(Integer.valueOf(900), resource.getPriceFen());
Assert.assertEquals(1, resource.getAppResourceListList().size());
assertPremiumSpec(resource.getAppResourceListList().get(0),
"源码 + 数据库 + 万字文档 + PPT",
"https://pan.quark.cn/s/example", 900, 1);
ArgumentCaptor<AppBlogArticle> articleCaptor = ArgumentCaptor.forClass(AppBlogArticle.class);
verify(articleMapper).insertAppBlogArticle(articleCaptor.capture());
Assert.assertEquals(Long.valueOf(7L), articleCaptor.getValue().getArticleType());
Assert.assertEquals("https://pan.quark.cn/s/example", code.getDiskLink());
}
@Test
public void repairsArticleLeftByPreviousFailedConversion()
{

View File

@@ -0,0 +1,44 @@
package com.ruoyi.office.service.impl;
import com.ruoyi.office.domain.TtFile;
import com.ruoyi.office.mapper.TtFileMapper;
import org.junit.Assert;
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 java.util.List;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@RunWith(MockitoJUnitRunner.class)
public class TtFileServiceImplTest {
@InjectMocks
private TtFileServiceImpl service;
@Mock
private TtFileMapper mapper;
@Test
public void insertOrdersNumberedHttpsImagesAndKeepsNames() {
TtFile request = new TtFile();
request.setCodeName("测试项目");
request.setPictureFile("https://img.yidaima.cn/2_管理端数据仪表盘-bbbb,"
+ "https://img.yidaima.cn/1_统一登录入口-aaaa");
Assert.assertEquals(2, service.insertTtFile(request));
ArgumentCaptor<TtFile> saved = ArgumentCaptor.forClass(TtFile.class);
verify(mapper, times(2)).insertTtFile(saved.capture());
List<TtFile> files = saved.getAllValues();
Assert.assertEquals("统一登录入口", files.get(0).getFileName());
Assert.assertEquals("https://img.yidaima.cn/1_统一登录入口-aaaa", files.get(0).getFileUrl());
Assert.assertEquals("管理端数据仪表盘", files.get(1).getFileName());
Assert.assertEquals("https://img.yidaima.cn/2_管理端数据仪表盘-bbbb", files.get(1).getFileUrl());
Assert.assertEquals("测试项目", files.get(0).getCodeName());
}
}

View File

@@ -114,6 +114,55 @@ public class TtProjectInfoServiceImplTest
Assert.assertEquals(1, result.getUnchangedCount());
}
@Test
public void updatesExistingProjectWhenProjectNumberMatches()
{
TtProjectInfo existing = new TtProjectInfo();
existing.setId(31);
existing.setProjectNum("s031");
existing.setProjectName("旧项目名称");
existing.setProjectName1("旧源码名称");
existing.setProjectUrl("https://pan.quark.cn/s/old");
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.singletonList(existing));
when(projectInfoMapper.updateProjectQuarkUrl(31,
"https://pan.quark.cn/s/741bf63df1bb")).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
verify(projectInfoMapper).updateProjectQuarkUrl(31,
"https://pan.quark.cn/s/741bf63df1bb");
verify(projectInfoMapper, never()).insertTtProjectInfo(any(TtProjectInfo.class));
Assert.assertEquals(1, result.getUpdatedCount());
}
@Test
public void updatesExistingProjectWhenSourceNameMatches()
{
quarkRow.setProjectName("婚纱摄影管理系统");
TtProjectInfo existing = new TtProjectInfo();
existing.setId(31);
existing.setProjectNum("S999");
existing.setProjectName("另一个项目名称");
existing.setProjectName1("婚纱摄影管理系统");
existing.setProjectUrl("https://pan.quark.cn/s/old");
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.singletonList(existing));
when(projectInfoMapper.updateProjectQuarkUrl(31,
"https://pan.quark.cn/s/741bf63df1bb")).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
verify(projectInfoMapper).updateProjectQuarkUrl(31,
"https://pan.quark.cn/s/741bf63df1bb");
verify(projectInfoMapper, never()).insertTtProjectInfo(any(TtProjectInfo.class));
Assert.assertEquals(1, result.getUpdatedCount());
}
@Test
public void updatesOnlyBaiduLinkForExistingProjectAndAddsPasscode()
{

View File

@@ -1,7 +1,10 @@
package com.ruoyi.office.service.impl;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import com.sun.net.httpserver.HttpServer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
@@ -9,11 +12,13 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.office.domain.PublishMaterial;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
import com.ruoyi.office.domain.TtPublishTask;
import com.ruoyi.office.mapper.TtPublishTaskMapper;
import com.ruoyi.office.service.CopyTemplateRenderer;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.ITtCopyTemplateService;
import com.ruoyi.office.service.ITtFileService;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -26,6 +31,7 @@ import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
@@ -47,6 +53,9 @@ public class TtPublishTaskServiceImplTest
@Mock
private ITtCopyTemplateService copyTemplateService;
@Mock
private ITtFileService fileService;
@Mock
private CopyTemplateRenderer copyTemplateRenderer;
@@ -65,6 +74,7 @@ public class TtPublishTaskServiceImplTest
code.setVideoFile("/profile/demo.mp4");
when(codeService.selectTtCodeByCodeId(40L)).thenReturn(code);
when(copyTemplateService.selectEnabledTemplates("CODE")).thenReturn(Collections.emptyList());
when(fileService.selectTtFileByCodeName(code.getCodeName())).thenReturn(Collections.emptyList());
SysUser user = new SysUser();
user.setUserName("publisher");
@@ -80,13 +90,21 @@ public class TtPublishTaskServiceImplTest
}
@Test
public void leavesMediaEmptyForManualSelection()
public void loadsProjectImagesFromFileManagement()
{
TtFile first = projectFile(1L, "http://img.yidaima.cn/1-home.png");
TtFile duplicate = projectFile(2L, "http://img.yidaima.cn/1-home.png");
TtFile second = projectFile(3L, "http://img.yidaima.cn/2-detail.jpg");
when(fileService.selectTtFileByCodeName(code.getCodeName()))
.thenReturn(Arrays.asList(first, duplicate, second));
PublishMaterial material = service.selectProjectMaterial(40L, "xiaohongshu");
Assert.assertEquals(code.getCodeName(), material.getTitle());
Assert.assertNull(material.getVideoUrl());
Assert.assertTrue(material.getImageUrls().isEmpty());
Assert.assertEquals(Arrays.asList(
"http://img.yidaima.cn/1-home.png",
"http://img.yidaima.cn/2-detail.jpg"), material.getImageUrls());
Assert.assertNull(material.getCoverUrl());
}
@@ -350,6 +368,85 @@ public class TtPublishTaskServiceImplTest
? new java.util.Date() : created.getCreateTime()));
}
@Test
public void followsRedirectWhenReadingRemotePublishImage() throws Exception
{
byte[] image = "test-png-content".getBytes(StandardCharsets.UTF_8);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
int port = server.getAddress().getPort();
server.createContext("/redirect", exchange -> {
exchange.getResponseHeaders().set("Location", "http://127.0.0.1:" + port + "/image");
exchange.sendResponseHeaders(301, -1);
exchange.close();
});
server.createContext("/image", exchange -> {
exchange.getResponseHeaders().set("Content-Type", "image/png");
exchange.sendResponseHeaders(200, image.length);
exchange.getResponseBody().write(image);
exchange.close();
});
server.start();
try
{
TtPublishTask task = new TtPublishTask();
task.setTaskId("redirect-task");
task.setImageUrls("[\"http://127.0.0.1:" + port + "/redirect\"]");
when(publishTaskMapper.selectPublishTaskByBridge("redirect-task", "bridge-token"))
.thenReturn(task);
MockHttpServletResponse response = new MockHttpServletResponse();
service.writeBridgeAsset("redirect-task", "bridge-token", "image", 0, response);
Assert.assertEquals("image/png", response.getContentType());
Assert.assertArrayEquals(image, response.getContentAsByteArray());
}
finally
{
server.stop(0);
}
}
@Test
public void encodesUnicodePathWhenReadingRemotePublishImage() throws Exception
{
byte[] image = "unicode-path-image".getBytes(StandardCharsets.UTF_8);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
int port = server.getAddress().getPort();
server.createContext("/", exchange -> {
if (!"/%E5%9B%BE%E7%89%87.png".equals(exchange.getRequestURI().getRawPath()))
{
exchange.sendResponseHeaders(404, -1);
exchange.close();
return;
}
exchange.getResponseHeaders().set("Content-Type", "image/png");
exchange.sendResponseHeaders(200, image.length);
exchange.getResponseBody().write(image);
exchange.close();
});
server.start();
try
{
TtPublishTask task = new TtPublishTask();
task.setTaskId("unicode-path-task");
task.setImageUrls("[\"http://127.0.0.1:" + port + "/图片.png\"]");
when(publishTaskMapper.selectPublishTaskByBridge("unicode-path-task", "bridge-token"))
.thenReturn(task);
MockHttpServletResponse response = new MockHttpServletResponse();
service.writeBridgeAsset("unicode-path-task", "bridge-token", "image", 0, response);
Assert.assertEquals("image/png", response.getContentType());
Assert.assertArrayEquals(image, response.getContentAsByteArray());
}
finally
{
server.stop(0);
}
}
private TtCopyTemplate template(Long id, String name, String group)
{
TtCopyTemplate template = new TtCopyTemplate();
@@ -360,4 +457,13 @@ public class TtPublishTaskServiceImplTest
template.setStatus("0");
return template;
}
private TtFile projectFile(Long id, String url)
{
TtFile file = new TtFile();
file.setFileId(id);
file.setCodeName(code.getCodeName());
file.setFileUrl(url);
return file;
}
}