|
|
@@ -0,0 +1,430 @@
|
|
|
+package com.fs.feishu.service;
|
|
|
+
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
+import com.fs.feishu.config.FeiShuConfig;
|
|
|
+import com.fs.feishu.model.FeishuFileDTO;
|
|
|
+import com.fs.feishu.model.FeishuLinkParam;
|
|
|
+import com.fs.feishu.util.SecureTokenUtil;
|
|
|
+import com.fs.common.exception.CustomException;
|
|
|
+import com.fs.course.config.CourseConfig;
|
|
|
+import com.fs.course.domain.FsUserCourseVideo;
|
|
|
+import com.fs.course.mapper.FsUserCourseVideoMapper;
|
|
|
+import com.fs.system.service.ISysConfigService;
|
|
|
+import com.lark.oapi.Client;
|
|
|
+import com.lark.oapi.service.docx.v1.model.*;
|
|
|
+import com.lark.oapi.service.drive.v1.model.DeleteFileReq;
|
|
|
+import com.lark.oapi.service.drive.v1.model.DeleteFileResp;
|
|
|
+import com.lark.oapi.service.drive.v1.model.ListFileReq;
|
|
|
+import com.lark.oapi.service.drive.v1.model.ListFileResp;
|
|
|
+import com.lark.oapi.service.drive.v2.model.PatchPermissionPublicReq;
|
|
|
+import com.lark.oapi.service.drive.v2.model.PermissionPublic;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import javax.annotation.PostConstruct;
|
|
|
+import java.io.UnsupportedEncodingException;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+@Component
|
|
|
+@Slf4j
|
|
|
+public class FeiShuService {
|
|
|
+
|
|
|
+ private final String COURSE_PATH = "/feishu/pages_course/videovip?token=%s";
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private FeiShuConfig feishuConfig;
|
|
|
+ @Autowired
|
|
|
+ private ISysConfigService configService;
|
|
|
+ @Autowired
|
|
|
+ private FsUserCourseVideoMapper videoMapper;
|
|
|
+
|
|
|
+ private Client client;
|
|
|
+
|
|
|
+ @PostConstruct
|
|
|
+ public void init() {
|
|
|
+ this.client = Client.newBuilder(feishuConfig.getAppId(), feishuConfig.getAppSecret()).logReqAtDebug(true).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 复制飞书看课链接
|
|
|
+ */
|
|
|
+ public String getFeishuCourseLink(Long companyUserId, Long videoId, Long periodId) {
|
|
|
+ if (companyUserId == null || videoId == null) {
|
|
|
+ throw new CustomException("用户ID和视频ID不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
|
|
|
+ if (userCourseVideo == null) {
|
|
|
+ throw new CustomException("视频不存在: " + videoId);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 创建云文档
|
|
|
+ String documentId = createDocument(userCourseVideo.getTitle());
|
|
|
+
|
|
|
+ // 拼接看课url
|
|
|
+ String url = buildCourseLink(companyUserId, videoId, periodId);
|
|
|
+
|
|
|
+ // 创建iframe块
|
|
|
+ createIframeBlock(documentId, url);
|
|
|
+
|
|
|
+ // 更新文档权限
|
|
|
+ changeDocumentPermissions(documentId);
|
|
|
+
|
|
|
+ // 返回飞书看课链接
|
|
|
+ return "https://www.feishu.cn/docx/" + documentId;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 复制飞书看课新链接
|
|
|
+ */
|
|
|
+ public String getFeishuCourseNewLink(FeishuLinkParam param) {
|
|
|
+ if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
|
|
|
+ throw new CustomException("参数不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
|
|
|
+ if (userCourseVideo == null) {
|
|
|
+ throw new CustomException("视频不存在: " + param.getVideoId());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // 创建云文档
|
|
|
+ String documentId = createDocument(userCourseVideo.getTitle());
|
|
|
+
|
|
|
+ // 拼接新看课url
|
|
|
+ String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(), param.getCourseId(), param.getVideoId(), param.getPeriodId(), param.getProjectId(),param.getUserId(),param.getId());
|
|
|
+
|
|
|
+ // 创建iframe块
|
|
|
+ createIframeBlock(documentId, url);
|
|
|
+
|
|
|
+ // 更新文档权限
|
|
|
+ changeDocumentPermissions(documentId);
|
|
|
+
|
|
|
+ // 返回飞书看课链接
|
|
|
+ return "https://www.feishu.cn/docx/" + documentId;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 复制飞书注册链接
|
|
|
+ */
|
|
|
+ public String getFeishuRegisterLink(FeishuLinkParam param) {
|
|
|
+ if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
|
|
|
+ throw new CustomException("参数不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
|
|
|
+ if (userCourseVideo == null) {
|
|
|
+ throw new CustomException("视频不存在: " + param.getVideoId());
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 创建云文档
|
|
|
+ String documentId = createDocument(userCourseVideo.getTitle() + "-注册");
|
|
|
+
|
|
|
+ // 拼接授权url
|
|
|
+ String authUrl = buildAuthLink(param.getCompanyId(), param.getCompanyUserId(),
|
|
|
+ param.getCourseId(), param.getVideoId(), param.getProjectId(),param.getId());
|
|
|
+
|
|
|
+ // 创建关注公众号 text + link block
|
|
|
+ createTextLinkBlock(documentId, authUrl);
|
|
|
+ // 更新文档权限
|
|
|
+ changeDocumentPermissions(documentId);
|
|
|
+
|
|
|
+ // 返回飞书看课链接
|
|
|
+ return "https://www.feishu.cn/docx/" + documentId;
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new CustomException("创建飞书注册链接失败: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建云文档
|
|
|
+ */
|
|
|
+ private String createDocument(String title) throws Exception {
|
|
|
+ CreateDocumentReq req = CreateDocumentReq.newBuilder()
|
|
|
+ .createDocumentReqBody(CreateDocumentReqBody.newBuilder().title(title).build())
|
|
|
+ .build();
|
|
|
+ CreateDocumentResp resp = client.docx().v1().document().create(req);
|
|
|
+
|
|
|
+ CreateDocumentRespBody data = resp.getData();
|
|
|
+ return data.getDocument().getDocumentId();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拼接看课url
|
|
|
+ */
|
|
|
+ private String buildCourseLink(Long companyUserId, Long videoId, Long periodId) {
|
|
|
+ String json = configService.selectConfigByKey("course.config");
|
|
|
+ CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
|
|
|
+
|
|
|
+ // 生成安全令牌
|
|
|
+ Long timestamp = System.currentTimeMillis() / 1000;
|
|
|
+ String token = SecureTokenUtil.generateToken(companyUserId, videoId, periodId, timestamp);
|
|
|
+
|
|
|
+ return config.getFeishuLinkDomainName() + String.format(COURSE_PATH, token);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拼接看课url(课程参数JSON方式,适配飞书iframe,双重URL编码)
|
|
|
+ */
|
|
|
+ private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId, Long videoId, Long periodId, Long projectId,Long userId,Long id) {
|
|
|
+ String json = configService.selectConfigByKey("course.config");
|
|
|
+ CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
|
|
|
+
|
|
|
+ // 构造课程参数 JSON
|
|
|
+ Map<String, Object> courseParam = new LinkedHashMap<>();
|
|
|
+ courseParam.put("periodId", periodId);
|
|
|
+ courseParam.put("companyUserId", companyUserId);
|
|
|
+ courseParam.put("videoId", videoId);
|
|
|
+ courseParam.put("projectId", projectId);
|
|
|
+ courseParam.put("companyId", companyId);
|
|
|
+ courseParam.put("courseId", courseId);
|
|
|
+ courseParam.put("id", id);
|
|
|
+ String courseJson = JSONUtil.toJsonStr(courseParam);
|
|
|
+ try {
|
|
|
+ // 第一次编码:对JSON进行URL编码
|
|
|
+ String encodedJson = URLEncoder.encode(courseJson, "UTF-8");
|
|
|
+ // 拼接完整URL(userId与course参数同级)
|
|
|
+ String baseUrl = config.getFeishuNewLinkDomainName();
|
|
|
+ String fullUrl = baseUrl + "/feishuCourse/pages_course/videovip?course=" + encodedJson + "&userId=" +userId;
|
|
|
+ // 第二次编码:对整个URL进行URL编码(适配飞书iframe创建要求)
|
|
|
+ return URLEncoder.encode(fullUrl, "UTF-8");
|
|
|
+ } catch (UnsupportedEncodingException e) {
|
|
|
+ throw new CustomException("看课URL拼接失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 拼接授权url(课程参数JSON方式,适配飞书iframe,双重URL编码)
|
|
|
+ */
|
|
|
+ private String buildAuthLink(Long companyId, Long companyUserId, Long courseId, Long videoId, Long projectId,Long id) {
|
|
|
+ String json = configService.selectConfigByKey("course.config");
|
|
|
+ CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
|
|
|
+
|
|
|
+ Map<String, Object> authParam = new LinkedHashMap<>();
|
|
|
+ authParam.put("companyUserId", companyUserId);
|
|
|
+ authParam.put("videoId", videoId);
|
|
|
+ authParam.put("projectId", projectId);
|
|
|
+ authParam.put("companyId", companyId);
|
|
|
+ authParam.put("courseId", courseId);
|
|
|
+ authParam.put("id", id);
|
|
|
+ String authJson = JSONUtil.toJsonStr(authParam);
|
|
|
+
|
|
|
+ try {
|
|
|
+ String encodedJson = URLEncoder.encode(authJson, "UTF-8");
|
|
|
+ String baseUrl =config.getFeishuNewLinkDomainName();
|
|
|
+ String fullUrl = baseUrl + "/feishuCourse/pages_course/wxauth?course=" + encodedJson;
|
|
|
+ return URLEncoder.encode(fullUrl, "UTF-8");
|
|
|
+ } catch (UnsupportedEncodingException e) {
|
|
|
+ throw new CustomException("授权URL拼接失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建iframe块
|
|
|
+ */
|
|
|
+ private void createIframeBlock(String documentId, String url) throws Exception {
|
|
|
+ CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
|
|
|
+ .documentId(documentId)
|
|
|
+ .blockId(documentId)
|
|
|
+ .documentRevisionId(-1)
|
|
|
+ .createDocumentBlockChildrenReqBody(CreateDocumentBlockChildrenReqBody.newBuilder()
|
|
|
+ .children(new Block[] {
|
|
|
+ Block.newBuilder()
|
|
|
+ .blockType(26)
|
|
|
+ .iframe(Iframe.newBuilder()
|
|
|
+ .component(IframeComponent.newBuilder()
|
|
|
+ .iframeType(1)
|
|
|
+ .url(url).build()
|
|
|
+ ).build()
|
|
|
+ )
|
|
|
+ .build()
|
|
|
+ })
|
|
|
+ .index(0)
|
|
|
+ .build())
|
|
|
+ .build();
|
|
|
+
|
|
|
+ // 发起请求
|
|
|
+ client.docx().v1().documentBlockChildren().create(req);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建 text + link block
|
|
|
+ */
|
|
|
+ private void createTextLinkBlock(String documentId, String url) throws Exception {
|
|
|
+
|
|
|
+ CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
|
|
|
+ .documentId(documentId)
|
|
|
+ .blockId(documentId)
|
|
|
+ .documentRevisionId(-1)
|
|
|
+ .createDocumentBlockChildrenReqBody(
|
|
|
+ CreateDocumentBlockChildrenReqBody.newBuilder()
|
|
|
+ .index(0)
|
|
|
+ .children(new Block[] {
|
|
|
+ Block.newBuilder()
|
|
|
+ .blockType(2)
|
|
|
+ .text(Text.newBuilder()
|
|
|
+ .elements(new TextElement[] {
|
|
|
+ TextElement.newBuilder()
|
|
|
+ .textRun(TextRun.newBuilder()
|
|
|
+ .content("观看课程")
|
|
|
+ .textElementStyle(TextElementStyle.newBuilder()
|
|
|
+ .link(Link.newBuilder()
|
|
|
+ .url(url)
|
|
|
+ .build())
|
|
|
+ .build())
|
|
|
+ .build())
|
|
|
+ .build()
|
|
|
+ })
|
|
|
+ .build())
|
|
|
+ .build()
|
|
|
+ })
|
|
|
+ .build())
|
|
|
+ .build();
|
|
|
+
|
|
|
+ client.docx().v1().documentBlockChildren().create(req);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取飞书云文档列表
|
|
|
+ */
|
|
|
+ public List<FeishuFileDTO> listFiles(int pageSize) throws Exception {
|
|
|
+
|
|
|
+ ListFileReq req = ListFileReq.newBuilder()
|
|
|
+ .pageSize(pageSize)
|
|
|
+ .orderBy("EditedTime")
|
|
|
+ .direction("DESC")
|
|
|
+ .build();
|
|
|
+
|
|
|
+ ListFileResp resp = client.drive().v1().file().list(req);
|
|
|
+
|
|
|
+ if (!resp.success()) {
|
|
|
+ throw new RuntimeException(
|
|
|
+ "Feishu error code:" + resp.getCode()
|
|
|
+ + ", msg:" + resp.getMsg()
|
|
|
+ + ", reqId:" + resp.getRequestId()
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ List<FeishuFileDTO> result = new ArrayList<>();
|
|
|
+
|
|
|
+ if (resp.getData() != null && resp.getData().getFiles() != null) {
|
|
|
+ com.lark.oapi.service.drive.v1.model.File[] files = resp.getData().getFiles();
|
|
|
+ Arrays.stream(files).forEach(file -> {
|
|
|
+ FeishuFileDTO dto = new FeishuFileDTO();
|
|
|
+ dto.setName(file.getName());
|
|
|
+ dto.setToken(file.getToken());
|
|
|
+ dto.setType(file.getType());
|
|
|
+ dto.setUrl(file.getUrl());
|
|
|
+ dto.setOwnerId(file.getOwnerId());
|
|
|
+ dto.setParentToken(file.getParentToken());
|
|
|
+ dto.setCreatedTime(file.getCreatedTime());
|
|
|
+ dto.setModifiedTime(file.getModifiedTime());
|
|
|
+
|
|
|
+ result.add(dto);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 修改文档权限
|
|
|
+ */
|
|
|
+ private void changeDocumentPermissions(String documentId) throws Exception {
|
|
|
+ PatchPermissionPublicReq req = PatchPermissionPublicReq.newBuilder()
|
|
|
+ .token(documentId)
|
|
|
+ .type("docx")
|
|
|
+ .permissionPublic(PermissionPublic.newBuilder()
|
|
|
+ .externalAccessEntity("open")
|
|
|
+ .securityEntity("anyone_can_view")
|
|
|
+ .commentEntity("anyone_can_edit")
|
|
|
+ .shareEntity("anyone")
|
|
|
+ .manageCollaboratorEntity("collaborator_full_access")
|
|
|
+ .linkShareEntity("anyone_readable")
|
|
|
+ .copyEntity("only_full_access")
|
|
|
+ .build())
|
|
|
+ .build();
|
|
|
+
|
|
|
+ // 发起请求
|
|
|
+ client.drive().v2().permissionPublic().patch(req);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据 token 删除飞书文档
|
|
|
+ *
|
|
|
+ * @param fileToken 文档token
|
|
|
+ * @param type 文档类型(docx / sheet / bitable / file 等)
|
|
|
+ */
|
|
|
+ public boolean deleteFile(String fileToken, String type) throws Exception {
|
|
|
+
|
|
|
+ DeleteFileReq req = DeleteFileReq.newBuilder()
|
|
|
+ .fileToken(fileToken)
|
|
|
+ .type(type)
|
|
|
+ .build();
|
|
|
+
|
|
|
+ DeleteFileResp resp = client.drive().v1().file().delete(req);
|
|
|
+
|
|
|
+ if (!resp.success()) {
|
|
|
+ throw new RuntimeException(
|
|
|
+ "Feishu delete failed, code=" + resp.getCode()
|
|
|
+ + ", msg=" + resp.getMsg()
|
|
|
+ + ", reqId=" + resp.getRequestId()
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除今天之前创建的飞书云文档
|
|
|
+ * 1. 查询云文档列表
|
|
|
+ * 2. 遍历比较创建时间(飞书返回秒级时间戳)
|
|
|
+ * 3. 创建时间在今天 00:00:00 之前的执行删除
|
|
|
+ */
|
|
|
+ public void deleteFilesBeforeToday() throws Exception {
|
|
|
+ // 今天 00:00:00 的时间戳(秒)
|
|
|
+ Calendar today = Calendar.getInstance();
|
|
|
+ today.set(Calendar.HOUR_OF_DAY, 0);
|
|
|
+ today.set(Calendar.MINUTE, 0);
|
|
|
+ today.set(Calendar.SECOND, 0);
|
|
|
+ today.set(Calendar.MILLISECOND, 0);
|
|
|
+ long todayStartSec = today.getTimeInMillis() / 1000;
|
|
|
+
|
|
|
+ List<FeishuFileDTO> files = listFiles(200);
|
|
|
+ int total = files.size();
|
|
|
+ int deleted = 0;
|
|
|
+ for (FeishuFileDTO file : files) {
|
|
|
+ try {
|
|
|
+ // 飞书返回的 createdTime 为秒级时间戳
|
|
|
+ long createdTime = Long.parseLong(file.getCreatedTime());
|
|
|
+ if (createdTime < todayStartSec) {
|
|
|
+ String type = file.getType();
|
|
|
+ // wiki 类型无法直接删除,跳过
|
|
|
+ if ("wiki".equalsIgnoreCase(type)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ boolean ok = deleteFile(file.getToken(), type);
|
|
|
+ if (ok) {
|
|
|
+ deleted++;
|
|
|
+ log.info("已删除: {} | type={} | createdTime={}", file.getName(), type, file.getCreatedTime());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("删除失败: {} | token={} | err={}", file.getName(), file.getToken(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("飞书云文档清理完成 | 列表总数={} | 删除成功={}", total, deleted);
|
|
|
+ }
|
|
|
+
|
|
|
+}
|