Add multi-platform publishing workflow

This commit is contained in:
王鹏
2026-09-14 17:40:52 +08:00
parent a466caa793
commit 0e24c9e235
41 changed files with 4980 additions and 58 deletions

View File

@@ -57,11 +57,11 @@ public class TtCopyTemplateController extends BaseController {
}
/**
* 查询所有启用的文案模板(供源码明细页动态按钮使用,无需分页权限)
* 按场景查询所有启用的文案模板(无需分页权限)
*/
@GetMapping("/enabled")
public AjaxResult listEnabled() {
return success(ttCopyTemplateService.selectEnabledTemplates());
public AjaxResult listEnabled(@RequestParam(value = "templateScene", required = false) String templateScene) {
return success(ttCopyTemplateService.selectEnabledTemplates(templateScene));
}
/**

View File

@@ -0,0 +1,105 @@
package com.ruoyi.office.controller;
import java.io.IOException;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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 com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.office.domain.PublishBridgeTask;
import com.ruoyi.office.domain.PublishMaterial;
import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.PublishStatusUpdate;
import com.ruoyi.office.domain.TtPublishTask;
import com.ruoyi.office.service.ITtPublishTaskService;
/**
* 后台发布管理与浏览器扩展桥接接口。
*/
@RestController
@RequestMapping("/office/publish")
public class TtPublishTaskController extends BaseController
{
@Autowired
private ITtPublishTaskService publishTaskService;
@PreAuthorize("@ss.hasPermi('office:publish:list')")
@GetMapping("/list")
public TableDataInfo list(PublishProjectRow query)
{
startPage();
List<PublishProjectRow> list = publishTaskService.selectPublishProjectList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('office:publish:query')")
@GetMapping("/material/{codeId}")
public AjaxResult material(@PathVariable Long codeId, @RequestParam String platform)
{
PublishMaterial material = publishTaskService.selectProjectMaterial(codeId, platform);
return AjaxResult.success(material);
}
@PreAuthorize("@ss.hasPermi('office:publish:add')")
@Log(title = "创建平台发布任务", businessType = BusinessType.INSERT)
@PostMapping("/task")
public AjaxResult createTask(@RequestBody TtPublishTask task)
{
return AjaxResult.success("发布任务已创建", publishTaskService.createPublishTask(task));
}
@PreAuthorize("@ss.hasPermi('office:publish:add')")
@Log(title = "创建多平台发布任务", businessType = BusinessType.INSERT)
@PostMapping("/tasks")
public AjaxResult createTasks(@RequestBody List<TtPublishTask> tasks)
{
return AjaxResult.success("多平台发布任务已创建", publishTaskService.createPublishTasks(tasks));
}
@Anonymous
@CrossOrigin(origins = "*")
@GetMapping("/bridge/task/{taskId}")
public AjaxResult bridgeTask(@PathVariable String taskId, @RequestParam String token)
{
PublishBridgeTask task = publishTaskService.selectBridgeTask(taskId, token);
return AjaxResult.success(task);
}
@Anonymous
@CrossOrigin(origins = "*")
@PostMapping("/bridge/task/{taskId}/status")
public AjaxResult bridgeStatus(@PathVariable String taskId,
@RequestParam String token,
@RequestBody PublishStatusUpdate update)
{
return AjaxResult.success(publishTaskService.updateBridgeStatus(taskId, token, update));
}
@Anonymous
@CrossOrigin(origins = "*")
@GetMapping({
"/bridge/task/{taskId}/asset/{kind}",
"/bridge/task/{taskId}/asset/{kind}/{index}"
})
public void bridgeAsset(@PathVariable String taskId,
@PathVariable String kind,
@PathVariable(required = false) Integer index,
@RequestParam String token,
HttpServletResponse response) throws IOException
{
publishTaskService.writeBridgeAsset(taskId, token, kind, index, response);
}
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 浏览器扩展可读取的最小发布任务数据,不包含后台身份凭证。
*/
public class PublishBridgeTask
{
private String taskId;
private String platform;
private String title;
private String content;
private List<String> tags = new ArrayList<>();
private boolean hasVideo;
private boolean hasCover;
private int imageCount;
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public List<String> getTags() { return tags; }
public void setTags(List<String> tags) { this.tags = tags; }
public boolean isHasVideo() { return hasVideo; }
public void setHasVideo(boolean hasVideo) { this.hasVideo = hasVideo; }
public boolean isHasCover() { return hasCover; }
public void setHasCover(boolean hasCover) { this.hasCover = hasCover; }
public int getImageCount() { return imageCount; }
public void setImageCount(int imageCount) { this.imageCount = imageCount; }
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 创建发布任务前返回给管理页面的项目素材。
*/
public class PublishMaterial
{
private Long codeId;
private String codeName;
private String title;
private String content;
private Long templateId;
private String templateName;
private String templateGroup;
private String tags;
private String videoUrl;
private String coverUrl;
private List<String> imageUrls = new ArrayList<>();
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public String getCodeName() { return codeName; }
public void setCodeName(String codeName) { this.codeName = codeName; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public Long getTemplateId() { return templateId; }
public void setTemplateId(Long templateId) { this.templateId = templateId; }
public String getTemplateName() { return templateName; }
public void setTemplateName(String templateName) { this.templateName = templateName; }
public String getTemplateGroup() { return templateGroup; }
public void setTemplateGroup(String templateGroup) { this.templateGroup = templateGroup; }
public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; }
public String getVideoUrl() { return videoUrl; }
public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; }
public String getCoverUrl() { return coverUrl; }
public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; }
public List<String> getImageUrls() { return imageUrls; }
public void setImageUrls(List<String> imageUrls) { this.imageUrls = imageUrls; }
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.office.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 发布管理页面中的项目聚合行。
*/
public class PublishProjectRow extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long codeId;
private String codeName;
private String xiaohongshuTaskId;
private String xiaohongshuStatus;
private String douyinTaskId;
private String douyinStatus;
private String wechatChannelsTaskId;
private String wechatChannelsStatus;
private String bilibiliTaskId;
private String bilibiliStatus;
private String xianyuTaskId;
private String xianyuStatus;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastPublishTime;
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public String getCodeName() { return codeName; }
public void setCodeName(String codeName) { this.codeName = codeName; }
public String getXiaohongshuTaskId() { return xiaohongshuTaskId; }
public void setXiaohongshuTaskId(String xiaohongshuTaskId) { this.xiaohongshuTaskId = xiaohongshuTaskId; }
public String getXiaohongshuStatus() { return xiaohongshuStatus; }
public void setXiaohongshuStatus(String xiaohongshuStatus) { this.xiaohongshuStatus = xiaohongshuStatus; }
public String getDouyinTaskId() { return douyinTaskId; }
public void setDouyinTaskId(String douyinTaskId) { this.douyinTaskId = douyinTaskId; }
public String getDouyinStatus() { return douyinStatus; }
public void setDouyinStatus(String douyinStatus) { this.douyinStatus = douyinStatus; }
public String getWechatChannelsTaskId() { return wechatChannelsTaskId; }
public void setWechatChannelsTaskId(String wechatChannelsTaskId) { this.wechatChannelsTaskId = wechatChannelsTaskId; }
public String getWechatChannelsStatus() { return wechatChannelsStatus; }
public void setWechatChannelsStatus(String wechatChannelsStatus) { this.wechatChannelsStatus = wechatChannelsStatus; }
public String getBilibiliTaskId() { return bilibiliTaskId; }
public void setBilibiliTaskId(String bilibiliTaskId) { this.bilibiliTaskId = bilibiliTaskId; }
public String getBilibiliStatus() { return bilibiliStatus; }
public void setBilibiliStatus(String bilibiliStatus) { this.bilibiliStatus = bilibiliStatus; }
public String getXianyuTaskId() { return xianyuTaskId; }
public void setXianyuTaskId(String xianyuTaskId) { this.xianyuTaskId = xianyuTaskId; }
public String getXianyuStatus() { return xianyuStatus; }
public void setXianyuStatus(String xianyuStatus) { this.xianyuStatus = xianyuStatus; }
public Date getLastPublishTime() { return lastPublishTime; }
public void setLastPublishTime(Date lastPublishTime) { this.lastPublishTime = lastPublishTime; }
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.office.domain;
/** 浏览器扩展发布状态回传。 */
public class PublishStatusUpdate
{
private String status;
private String message;
private String error;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getError() { return error; }
public void setError(String error) { this.error = error; }
}

View File

@@ -25,6 +25,10 @@ public class TtCopyTemplate extends BaseEntity {
@Excel(name = "模板分组")
private String templateGroup;
/** 模板场景(CODE源码文案 DELIVERY发货文案) */
@Excel(name = "模板场景")
private String templateScene;
/** 文案模板内容,支持 {变量} 占位符 */
@Excel(name = "模板内容")
private String templateBody;
@@ -61,6 +65,14 @@ public class TtCopyTemplate extends BaseEntity {
return templateGroup;
}
public void setTemplateScene(String templateScene) {
this.templateScene = templateScene;
}
public String getTemplateScene() {
return templateScene;
}
public void setTemplateBody(String templateBody) {
this.templateBody = templateBody;
}
@@ -91,6 +103,7 @@ public class TtCopyTemplate extends BaseEntity {
.append("templateId", getTemplateId())
.append("templateName", getTemplateName())
.append("templateGroup", getTemplateGroup())
.append("templateScene", getTemplateScene())
.append("templateBody", getTemplateBody())
.append("sortNum", getSortNum())
.append("status", getStatus())

View File

@@ -0,0 +1,81 @@
package com.ruoyi.office.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 多平台发布任务对象 tt_publish_task。
*/
public class TtPublishTask extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long publishId;
private String taskId;
private Long codeId;
private String codeName;
private String platform;
private String title;
private String content;
private String tags;
private String videoUrl;
private String imageUrls;
private String coverUrl;
private String status;
private String statusMessage;
private String lastError;
private String bridgeToken;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date bridgeExpiresAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startedAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date readyAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date finishedAt;
public Long getPublishId() { return publishId; }
public void setPublishId(Long publishId) { this.publishId = publishId; }
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public Long getCodeId() { return codeId; }
public void setCodeId(Long codeId) { this.codeId = codeId; }
public String getCodeName() { return codeName; }
public void setCodeName(String codeName) { this.codeName = codeName; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; }
public String getVideoUrl() { return videoUrl; }
public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; }
public String getImageUrls() { return imageUrls; }
public void setImageUrls(String imageUrls) { this.imageUrls = imageUrls; }
public String getCoverUrl() { return coverUrl; }
public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getStatusMessage() { return statusMessage; }
public void setStatusMessage(String statusMessage) { this.statusMessage = statusMessage; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public String getBridgeToken() { return bridgeToken; }
public void setBridgeToken(String bridgeToken) { this.bridgeToken = bridgeToken; }
public Date getBridgeExpiresAt() { return bridgeExpiresAt; }
public void setBridgeExpiresAt(Date bridgeExpiresAt) { this.bridgeExpiresAt = bridgeExpiresAt; }
public Date getStartedAt() { return startedAt; }
public void setStartedAt(Date startedAt) { this.startedAt = startedAt; }
public Date getReadyAt() { return readyAt; }
public void setReadyAt(Date readyAt) { this.readyAt = readyAt; }
public Date getFinishedAt() { return finishedAt; }
public void setFinishedAt(Date finishedAt) { this.finishedAt = finishedAt; }
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtCopyTemplate;
import org.apache.ibatis.annotations.Param;
/**
* 文案模板Mapper接口
@@ -24,7 +25,7 @@ public interface TtCopyTemplateMapper {
/**
* 查询所有启用的文案模板(按排序号升序)
*/
public List<TtCopyTemplate> selectEnabledTemplates();
public List<TtCopyTemplate> selectEnabledTemplates(@Param("templateScene") String templateScene);
/**
* 新增文案模板
@@ -45,4 +46,4 @@ public interface TtCopyTemplateMapper {
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.office.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.TtPublishTask;
public interface TtPublishTaskMapper
{
List<PublishProjectRow> selectPublishProjectList(PublishProjectRow query);
TtPublishTask selectPublishTaskByTaskId(String taskId);
TtPublishTask selectPublishTaskByBridge(@Param("taskId") String taskId,
@Param("bridgeToken") String bridgeToken);
int insertPublishTask(TtPublishTask task);
int updatePublishTaskStatus(TtPublishTask task);
}

View File

@@ -26,6 +26,11 @@ public interface ITtCopyTemplateService {
*/
public List<TtCopyTemplate> selectEnabledTemplates();
/**
* 按场景查询所有启用的文案模板
*/
public List<TtCopyTemplate> selectEnabledTemplates(String templateScene);
/**
* 新增文案模板
*/
@@ -45,4 +50,4 @@ public interface ITtCopyTemplateService {
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.office.service;
import java.io.IOException;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.office.domain.PublishBridgeTask;
import com.ruoyi.office.domain.PublishMaterial;
import com.ruoyi.office.domain.PublishProjectRow;
import com.ruoyi.office.domain.PublishStatusUpdate;
import com.ruoyi.office.domain.TtPublishTask;
public interface ITtPublishTaskService
{
List<PublishProjectRow> selectPublishProjectList(PublishProjectRow query);
PublishMaterial selectProjectMaterial(Long codeId, String platform);
TtPublishTask createPublishTask(TtPublishTask task);
List<TtPublishTask> createPublishTasks(List<TtPublishTask> tasks);
PublishBridgeTask selectBridgeTask(String taskId, String token);
TtPublishTask updateBridgeStatus(String taskId, String token, PublishStatusUpdate update);
void writeBridgeAsset(String taskId, String token, String kind, Integer index,
HttpServletResponse response) throws IOException;
}

View File

@@ -32,7 +32,12 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService {
@Override
public List<TtCopyTemplate> selectEnabledTemplates() {
return ttCopyTemplateMapper.selectEnabledTemplates();
return selectEnabledTemplates(null);
}
@Override
public List<TtCopyTemplate> selectEnabledTemplates(String templateScene) {
return ttCopyTemplateMapper.selectEnabledTemplates(templateScene);
}
@Override
@@ -56,4 +61,4 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService {
public int deleteTtCopyTemplateByTemplateId(Long templateId) {
return ttCopyTemplateMapper.deleteTtCopyTemplateByTemplateId(templateId);
}
}
}

View File

@@ -0,0 +1,524 @@
package com.ruoyi.office.service.impl;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import javax.servlet.http.HttpServletResponse;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.PublishBridgeTask;
import com.ruoyi.office.domain.PublishMaterial;
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.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.ITtPublishTaskService;
@Service
public class TtPublishTaskServiceImpl implements ITtPublishTaskService
{
private static final Set<String> PLATFORMS = Collections.unmodifiableSet(
new LinkedHashSet<>(Arrays.asList("xiaohongshu", "douyin", "wechat_channels", "bilibili", "xianyu")));
private static final Set<String> EXTENSION_STATUSES = Collections.unmodifiableSet(
new LinkedHashSet<>(Arrays.asList(
"OPENING", "LOGIN_REQUIRED", "DOWNLOADING", "UPLOADING",
"FILLING", "READY_TO_PUBLISH", "SUCCEEDED", "FAILED", "CANCELLED"
)));
@Autowired
private TtPublishTaskMapper publishTaskMapper;
@Autowired
private ITtCodeService codeService;
@Autowired
private ITtCopyTemplateService copyTemplateService;
@Autowired
private CopyTemplateRenderer copyTemplateRenderer;
@Autowired
private ObjectMapper objectMapper;
@Override
public List<PublishProjectRow> selectPublishProjectList(PublishProjectRow query)
{
return publishTaskMapper.selectPublishProjectList(query);
}
@Override
public PublishMaterial selectProjectMaterial(Long codeId, String platform)
{
String templateGroup = platformTemplateGroup(platform);
TtCode code = requireCode(codeId);
PublishMaterial material = new PublishMaterial();
material.setCodeId(code.getCodeId());
material.setCodeName(code.getCodeName());
material.setTitle(code.getCodeName());
TtCopyTemplate selectedTemplate = selectPublishTemplate(
copyTemplateService.selectEnabledTemplates("CODE"), templateGroup);
if (selectedTemplate != null)
{
material.setTemplateId(selectedTemplate.getTemplateId());
material.setTemplateName(selectedTemplate.getTemplateName());
material.setTemplateGroup(selectedTemplate.getTemplateGroup());
material.setContent(copyTemplateRenderer.render(selectedTemplate, code));
}
return material;
}
private TtCopyTemplate selectPublishTemplate(List<TtCopyTemplate> templates, String targetGroup)
{
if (templates == null || templates.isEmpty())
{
return null;
}
List<TtCopyTemplate> exactMatches = new ArrayList<>();
List<TtCopyTemplate> sharedMatches = new ArrayList<>();
for (TtCopyTemplate template : templates)
{
if (template == null || StringUtils.isEmpty(template.getTemplateGroup()))
{
continue;
}
String group = template.getTemplateGroup().trim();
if (targetGroup.equals(group))
{
exactMatches.add(template);
}
else if (group.contains(targetGroup))
{
sharedMatches.add(template);
}
}
List<TtCopyTemplate> candidates = exactMatches.isEmpty() ? sharedMatches : exactMatches;
if (candidates.isEmpty())
{
return null;
}
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
}
private String platformTemplateGroup(String platform)
{
if ("xiaohongshu".equals(platform)) return "小红书";
if ("douyin".equals(platform)) return "抖音";
if ("wechat_channels".equals(platform)) return "视频号";
if ("bilibili".equals(platform)) return "B站";
if ("xianyu".equals(platform)) return "闲鱼";
throw new ServiceException("不支持的发布平台");
}
@Override
public TtPublishTask createPublishTask(TtPublishTask task)
{
if (task == null || task.getCodeId() == null)
{
throw new ServiceException("请选择需要发布的项目");
}
if (!PLATFORMS.contains(task.getPlatform()))
{
throw new ServiceException("不支持的发布平台");
}
TtCode code = requireCode(task.getCodeId());
task.setTitle(normalizePlatformTitle(task.getPlatform(), task.getTitle()));
task.setContent(trimToNull(task.getContent()));
task.setTags(trimToNull(task.getTags()));
task.setVideoUrl(trimToNull(task.getVideoUrl()));
task.setCoverUrl(trimToNull(task.getCoverUrl()));
List<String> images = parseImageUrls(task.getImageUrls());
task.setImageUrls(writeJson(images));
if (StringUtils.isEmpty(task.getTitle()))
{
throw new ServiceException("发布标题不能为空");
}
int titleLimit = "xianyu".equals(task.getPlatform()) || "wechat_channels".equals(task.getPlatform()) ? 30
: ("bilibili".equals(task.getPlatform()) ? 80 : 100);
if (task.getTitle().length() > titleLimit)
{
throw new ServiceException("发布标题不能超过" + titleLimit + "个字符");
}
if (task.getContent() != null && task.getContent().length() > 5000)
{
throw new ServiceException("发布文案不能超过5000个字符");
}
if ("xiaohongshu".equals(task.getPlatform())
&& StringUtils.isEmpty(task.getVideoUrl()) && images.isEmpty())
{
throw new ServiceException("发布小红书至少需要一个视频或一张图片");
}
if (("douyin".equals(task.getPlatform()) || "wechat_channels".equals(task.getPlatform())
|| "bilibili".equals(task.getPlatform()))
&& StringUtils.isEmpty(task.getVideoUrl()))
{
throw new ServiceException("发布抖音、视频号或B站需要选择视频");
}
if ("xianyu".equals(task.getPlatform()))
{
if (images.isEmpty())
{
throw new ServiceException("发布闲鱼商品至少需要一张商品图片");
}
}
String username = SecurityUtils.getUsername();
task.setCodeName(code.getCodeName());
task.setTaskId("publish_" + UUID.randomUUID().toString().replace("-", ""));
task.setBridgeToken(UUID.randomUUID().toString().replace("-", ""));
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR, 4);
task.setBridgeExpiresAt(calendar.getTime());
task.setStatus("PENDING");
task.setStatusMessage("等待浏览器扩展接收任务");
task.setCreateBy(username);
task.setUpdateBy(username);
publishTaskMapper.insertPublishTask(task);
return task;
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<TtPublishTask> createPublishTasks(List<TtPublishTask> tasks)
{
if (tasks == null || tasks.isEmpty())
{
throw new ServiceException("请至少选择一个发布平台");
}
if (tasks.size() > PLATFORMS.size())
{
throw new ServiceException("一次最多创建" + PLATFORMS.size() + "个平台任务");
}
Set<String> selectedPlatforms = new LinkedHashSet<>();
List<TtPublishTask> created = new ArrayList<>();
for (TtPublishTask task : tasks)
{
if (task == null || !selectedPlatforms.add(task.getPlatform()))
{
throw new ServiceException("多平台发布中存在重复或无效的平台");
}
created.add(createPublishTask(task));
}
return created;
}
@Override
public PublishBridgeTask selectBridgeTask(String taskId, String token)
{
TtPublishTask task = requireBridgeTask(taskId, token);
List<String> images = parseImageUrls(task.getImageUrls());
PublishBridgeTask bridgeTask = new PublishBridgeTask();
bridgeTask.setTaskId(task.getTaskId());
bridgeTask.setPlatform(task.getPlatform());
bridgeTask.setTitle(task.getTitle());
bridgeTask.setContent(task.getContent());
bridgeTask.setTags(parseTags(task.getTags()));
bridgeTask.setHasVideo(StringUtils.isNotEmpty(task.getVideoUrl()));
bridgeTask.setHasCover(StringUtils.isNotEmpty(task.getCoverUrl()));
bridgeTask.setImageCount(images.size());
return bridgeTask;
}
@Override
public TtPublishTask updateBridgeStatus(String taskId, String token, PublishStatusUpdate update)
{
TtPublishTask existing = requireBridgeTask(taskId, token);
if (update == null || !EXTENSION_STATUSES.contains(update.getStatus()))
{
throw new ServiceException("扩展回传的发布状态无效");
}
TtPublishTask task = new TtPublishTask();
task.setTaskId(existing.getTaskId());
task.setStatus(update.getStatus());
task.setStatusMessage(limit(update.getMessage(), 255));
task.setLastError(limit(update.getError(), 1000));
task.setUpdateBy("browser-extension");
Date now = new Date();
if ("OPENING".equals(update.getStatus()))
{
task.setStartedAt(now);
}
if ("READY_TO_PUBLISH".equals(update.getStatus()))
{
task.setReadyAt(now);
}
if ("SUCCEEDED".equals(update.getStatus()) || "FAILED".equals(update.getStatus())
|| "CANCELLED".equals(update.getStatus()))
{
task.setFinishedAt(now);
}
publishTaskMapper.updatePublishTaskStatus(task);
return publishTaskMapper.selectPublishTaskByTaskId(taskId);
}
@Override
public void writeBridgeAsset(String taskId, String token, String kind, Integer index,
HttpServletResponse response) throws IOException
{
TtPublishTask task = requireBridgeTask(taskId, token);
String source = selectAssetSource(task, kind, index);
if (StringUtils.isEmpty(source))
{
throw new ServiceException("发布素材不存在");
}
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
response.setHeader("Cache-Control", "private, no-store");
int profileIndex = source.indexOf(Constants.RESOURCE_PREFIX + "/");
if (profileIndex >= 0)
{
String relative = source.substring(profileIndex + Constants.RESOURCE_PREFIX.length());
Path profileRoot = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize();
Path assetPath = profileRoot.resolve(relative.replaceFirst("^[\\\\/]+", "")).normalize();
if (!assetPath.startsWith(profileRoot) || !Files.isRegularFile(assetPath))
{
throw new ServiceException("发布素材文件不存在");
}
writeLocalAsset(assetPath.toFile(), response);
return;
}
writeRemoteAsset(source, response);
}
private TtCode requireCode(Long codeId)
{
TtCode code = codeService.selectTtCodeByCodeId(codeId);
if (code == null)
{
throw new ServiceException("项目不存在或已被删除");
}
return code;
}
private TtPublishTask requireBridgeTask(String taskId, String token)
{
if (StringUtils.isEmpty(taskId) || StringUtils.isEmpty(token))
{
throw new ServiceException("发布任务凭证不能为空");
}
TtPublishTask task = publishTaskMapper.selectPublishTaskByBridge(taskId, token);
if (task == null)
{
throw new ServiceException("发布任务不存在或任务凭证已过期");
}
return task;
}
private String selectAssetSource(TtPublishTask task, String kind, Integer index)
{
if ("video".equals(kind))
{
return task.getVideoUrl();
}
if ("cover".equals(kind))
{
return task.getCoverUrl();
}
if ("image".equals(kind))
{
List<String> images = parseImageUrls(task.getImageUrls());
int selected = index == null ? 0 : index;
if (selected >= 0 && selected < images.size())
{
return images.get(selected);
}
}
return null;
}
private void writeLocalAsset(File file, HttpServletResponse response) throws IOException
{
String contentType = Files.probeContentType(file.toPath());
response.setContentType(contentType == null ? "application/octet-stream" : contentType);
response.setContentLengthLong(file.length());
response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(file.getName()));
try (InputStream input = new FileInputStream(file))
{
copy(input, response.getOutputStream());
}
}
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);
}
String contentType = connection.getContentType();
response.setContentType(contentType == null ? "application/octet-stream" : contentType);
long length = connection.getContentLengthLong();
if (length >= 0)
{
response.setContentLengthLong(length);
}
String fileName = new File(url.getPath()).getName();
response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(fileName));
try (InputStream input = connection.getInputStream())
{
copy(input, response.getOutputStream());
}
finally
{
connection.disconnect();
}
}
private void copy(InputStream input, OutputStream output) throws IOException
{
byte[] buffer = new byte[8192];
int length;
while ((length = input.read(buffer)) >= 0)
{
output.write(buffer, 0, length);
}
output.flush();
}
private String encode(String value) throws IOException
{
return URLEncoder.encode(StringUtils.defaultIfEmpty(value, "publish-asset"), "UTF-8")
.replace("+", "%20");
}
private List<String> parseImageUrls(String value)
{
if (StringUtils.isEmpty(value))
{
return new ArrayList<>();
}
try
{
if (value.trim().startsWith("["))
{
List<String> parsed = objectMapper.readValue(value, new TypeReference<List<String>>() { });
return cleanUrls(parsed);
}
}
catch (Exception ignored)
{
// 兼容历史逗号分隔形式。
}
return cleanUrls(Arrays.asList(value.split(",")));
}
private List<String> cleanUrls(List<String> urls)
{
LinkedHashSet<String> result = new LinkedHashSet<>();
if (urls != null)
{
for (String url : urls)
{
String clean = trimToNull(url);
if (clean != null)
{
result.add(clean);
}
}
}
return new ArrayList<>(result);
}
private String writeJson(List<String> urls)
{
try
{
return objectMapper.writeValueAsString(urls == null ? Collections.emptyList() : urls);
}
catch (Exception e)
{
throw new ServiceException("发布图片数据格式不正确");
}
}
private List<String> parseTags(String value)
{
if (StringUtils.isEmpty(value))
{
return new ArrayList<>();
}
LinkedHashSet<String> tags = new LinkedHashSet<>();
for (String part : value.split("[,,#\\s]+"))
{
String tag = trimToNull(part);
if (tag != null)
{
tags.add(tag);
}
}
return new ArrayList<>(tags);
}
private String trimToNull(String value)
{
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private String normalizePlatformTitle(String platform, String value)
{
String title = trimToNull(value);
if (title == null || !"wechat_channels".equals(platform))
{
return title;
}
title = title.replaceAll("[【】\\[\\]()(){}<>]", " ")
.replaceAll("[^\\p{IsHan}A-Za-z0-9《》“”‘’\\\"'::++??%%℃,,、。!!\\s]", " ")
.replaceAll("\\s+", " ")
.trim();
return StringUtils.isEmpty(title) ? null : title;
}
private String limit(String value, int maxLength)
{
String clean = trimToNull(value);
if (clean == null || clean.length() <= maxLength) return clean;
return clean.substring(0, maxLength);
}
}

View File

@@ -8,6 +8,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="templateId" column="template_id" />
<result property="templateName" column="template_name" />
<result property="templateGroup" column="template_group" />
<result property="templateScene" column="template_scene" />
<result property="templateBody" column="template_body" />
<result property="sortNum" column="sort_num" />
<result property="status" column="status" />
@@ -16,7 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectTtCopyTemplateVo">
select template_id, template_name, template_group, template_body, sort_num, status, create_time, update_time
select template_id, template_name, template_group, template_scene, template_body, sort_num, status, create_time, update_time
from tt_copy_template
</sql>
@@ -25,6 +26,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<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="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
@@ -33,6 +35,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectEnabledTemplates" resultMap="TtCopyTemplateResult">
<include refid="selectTtCopyTemplateVo"/>
where status = '0'
<if test="templateScene != null and templateScene != ''"> and template_scene = #{templateScene}</if>
order by sort_num asc, create_time asc
</select>
@@ -46,6 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="templateName != null and templateName != ''">template_name,</if>
<if test="templateGroup != null and templateGroup != ''">template_group,</if>
<if test="templateScene != null and templateScene != ''">template_scene,</if>
<if test="templateBody != null">template_body,</if>
<if test="sortNum != null">sort_num,</if>
<if test="status != null and status != ''">status,</if>
@@ -55,6 +59,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="templateName != null and templateName != ''">#{templateName},</if>
<if test="templateGroup != null and templateGroup != ''">#{templateGroup},</if>
<if test="templateScene != null and templateScene != ''">#{templateScene},</if>
<if test="templateBody != null">#{templateBody},</if>
<if test="sortNum != null">#{sortNum},</if>
<if test="status != null and status != ''">#{status},</if>
@@ -68,6 +73,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="SET" suffixOverrides=",">
<if test="templateName != null and templateName != ''">template_name = #{templateName},</if>
<if test="templateGroup != null and templateGroup != ''">template_group = #{templateGroup},</if>
<if test="templateScene != null and templateScene != ''">template_scene = #{templateScene},</if>
<if test="templateBody != null">template_body = #{templateBody},</if>
<if test="sortNum != null">sort_num = #{sortNum},</if>
<if test="status != null and status != ''">status = #{status},</if>

View File

@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.office.mapper.TtPublishTaskMapper">
<resultMap type="TtPublishTask" id="PublishTaskResult">
<id property="publishId" column="publish_id" />
<result property="taskId" column="task_id" />
<result property="codeId" column="code_id" />
<result property="codeName" column="code_name" />
<result property="platform" column="platform" />
<result property="title" column="title" />
<result property="content" column="content" />
<result property="tags" column="tags" />
<result property="videoUrl" column="video_url" />
<result property="imageUrls" column="image_urls" />
<result property="coverUrl" column="cover_url" />
<result property="status" column="status" />
<result property="statusMessage" column="status_message" />
<result property="lastError" column="last_error" />
<result property="bridgeToken" column="bridge_token" />
<result property="bridgeExpiresAt" column="bridge_expires_at" />
<result property="startedAt" column="started_at" />
<result property="readyAt" column="ready_at" />
<result property="finishedAt" column="finished_at" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
</resultMap>
<resultMap type="PublishProjectRow" id="PublishProjectResult">
<id property="codeId" column="code_id" />
<result property="codeName" column="code_name" />
<result property="xiaohongshuTaskId" column="xiaohongshu_task_id" />
<result property="xiaohongshuStatus" column="xiaohongshu_status" />
<result property="douyinTaskId" column="douyin_task_id" />
<result property="douyinStatus" column="douyin_status" />
<result property="wechatChannelsTaskId" column="wechat_channels_task_id" />
<result property="wechatChannelsStatus" column="wechat_channels_status" />
<result property="bilibiliTaskId" column="bilibili_task_id" />
<result property="bilibiliStatus" column="bilibili_status" />
<result property="xianyuTaskId" column="xianyu_task_id" />
<result property="xianyuStatus" column="xianyu_status" />
<result property="lastPublishTime" column="last_publish_time" />
</resultMap>
<sql id="selectPublishTaskVo">
select t.publish_id, t.task_id, t.code_id, c.code_name, t.platform,
t.title, t.content, t.tags, t.video_url, t.image_urls, t.cover_url,
t.status, t.status_message, t.last_error, t.bridge_token,
t.bridge_expires_at, t.started_at, t.ready_at, t.finished_at,
t.create_by, t.create_time, t.update_by, t.update_time
from tt_publish_task t
inner join tt_code c on c.code_id = t.code_id
</sql>
<select id="selectPublishProjectList" parameterType="PublishProjectRow" resultMap="PublishProjectResult">
select c.code_id,
c.code_name,
x.task_id as xiaohongshu_task_id,
x.status as xiaohongshu_status,
d.task_id as douyin_task_id,
d.status as douyin_status,
w.task_id as wechat_channels_task_id,
w.status as wechat_channels_status,
b.task_id as bilibili_task_id,
b.status as bilibili_status,
y.task_id as xianyu_task_id,
y.status as xianyu_status,
greatest(
ifnull(x.update_time, '1970-01-01 00:00:00'),
ifnull(d.update_time, '1970-01-01 00:00:00'),
ifnull(w.update_time, '1970-01-01 00:00:00'),
ifnull(b.update_time, '1970-01-01 00:00:00'),
ifnull(y.update_time, '1970-01-01 00:00:00')
) as last_publish_time
from tt_code c
left join tt_publish_task x on x.publish_id = (
select max(tx.publish_id) from tt_publish_task tx
where tx.code_id = c.code_id and tx.platform = 'xiaohongshu'
)
left join tt_publish_task d on d.publish_id = (
select max(td.publish_id) from tt_publish_task td
where td.code_id = c.code_id and td.platform = 'douyin'
)
left join tt_publish_task w on w.publish_id = (
select max(tw.publish_id) from tt_publish_task tw
where tw.code_id = c.code_id and tw.platform = 'wechat_channels'
)
left join tt_publish_task b on b.publish_id = (
select max(tb.publish_id) from tt_publish_task tb
where tb.code_id = c.code_id and tb.platform = 'bilibili'
)
left join tt_publish_task y on y.publish_id = (
select max(ty.publish_id) from tt_publish_task ty
where ty.code_id = c.code_id and ty.platform = 'xianyu'
)
<where>
<if test="codeName != null and codeName != ''">
and c.code_name like concat('%', #{codeName}, '%')
</if>
</where>
order by c.code_id desc
</select>
<select id="selectPublishTaskByTaskId" parameterType="String" resultMap="PublishTaskResult">
<include refid="selectPublishTaskVo" />
where t.task_id = #{taskId}
limit 1
</select>
<select id="selectPublishTaskByBridge" resultMap="PublishTaskResult">
<include refid="selectPublishTaskVo" />
where t.task_id = #{taskId}
and t.bridge_token = #{bridgeToken}
and t.bridge_expires_at &gt; now()
limit 1
</select>
<insert id="insertPublishTask" parameterType="TtPublishTask" useGeneratedKeys="true" keyProperty="publishId">
insert into tt_publish_task
(task_id, code_id, platform, title, content, tags, video_url,
image_urls, cover_url, status, status_message, bridge_token,
bridge_expires_at, create_by, create_time, update_by, update_time)
values
(#{taskId}, #{codeId}, #{platform}, #{title}, #{content}, #{tags},
#{videoUrl}, #{imageUrls}, #{coverUrl}, #{status}, #{statusMessage},
#{bridgeToken}, #{bridgeExpiresAt}, #{createBy}, now(), #{updateBy}, now())
</insert>
<update id="updatePublishTaskStatus" parameterType="TtPublishTask">
update tt_publish_task
<set>
status = #{status},
status_message = #{statusMessage},
last_error = #{lastError},
<if test="startedAt != null">started_at = #{startedAt},</if>
<if test="readyAt != null">ready_at = #{readyAt},</if>
<if test="finishedAt != null">finished_at = #{finishedAt},</if>
update_by = #{updateBy},
update_time = now()
</set>
where task_id = #{taskId}
</update>
</mapper>