Explorar el Código

feat:飞书看课等

caoliqin hace 3 días
padre
commit
03bfd7c401

+ 32 - 0
fs-admin/src/main/java/com/fs/task/AuthLinkTokenCleanTask.java

@@ -0,0 +1,32 @@
+package com.fs.task;
+
+import com.fs.feishu.service.IFsAuthLinkTokenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 飞书授权链接token清理定时任务
+ * 每天凌晨3点清理 fs_auth_link_token 表中已过期的记录
+ */
+@Component("authLinkTokenCleanTask")
+@Slf4j
+public class AuthLinkTokenCleanTask {
+
+    @Autowired
+    private IFsAuthLinkTokenService authLinkTokenService;
+
+    /**
+     * 每天凌晨3点执行,清理已过期的token记录
+     */
+//    @Scheduled(cron = "0 0 3 * * ?")
+    public void cleanExpiredTokens() {
+        try {
+            log.info("授权token清理 - 定时任务开始");
+            int count = authLinkTokenService.cleanExpiredTokens();
+            log.info("授权token清理 - 定时任务完成 | 删除过期记录={}", count);
+        } catch (Exception e) {
+            log.error("授权token清理 - 定时任务执行失败", e);
+        }
+    }
+}

+ 127 - 5
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -8,6 +8,7 @@ import com.fs.common.annotation.Log;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyMiniapp;
@@ -27,10 +28,13 @@ import com.fs.course.param.newfs.FsCourseWatchAppParam;
 import com.fs.course.param.newfs.FsUserCourseListParam;
 import com.fs.course.param.newfs.UserCourseVideoPageParam;
 import com.fs.course.service.*;
+import com.fs.course.utils.WechatErrorUtil;
 import com.fs.course.vo.CompanyUserCourseFavoriteToggleVO;
 import com.fs.course.vo.FsCourseWatchLogListVO;
 import com.fs.course.vo.FsUserCourseParticipationRecordVO;
 import com.fs.course.vo.newfs.*;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.im.domain.FsImMsgSendLog;
 import com.fs.im.dto.OpenImResponseDTO;
 import com.fs.im.service.IFsImMsgSendDetailService;
@@ -38,6 +42,7 @@ import com.fs.im.service.IFsImMsgSendLogService;
 import com.fs.im.service.OpenIMService;
 import com.fs.im.vo.FsImMsgSendLogVO;
 import com.fs.live.service.ILiveService;
+import com.fs.system.service.ISysConfigService;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.Api;
@@ -102,6 +107,11 @@ public class FsUserCourseVideoController extends AppBaseController {
     @Autowired
     private IFsCompanyUserCourseFavoriteService companyUserCourseFavoriteService;
 
+    @Autowired
+    private FeiShuService feiShuService;
+    @Autowired
+    private ISysConfigService configService;
+
     @Login
     @GetMapping("/pageList")
     @ApiOperation("课程分页列表")
@@ -117,14 +127,17 @@ public class FsUserCourseVideoController extends AppBaseController {
     @ApiOperation("获取看课使用小程序")
     @GetMapping("/getLoginMiniAppId/{appId}")
     public R getLoginMiniAppId(@PathVariable String appId) {
-        CompanyMiniapp params =  new CompanyMiniapp();
+        CompanyMiniapp params = new CompanyMiniapp();
         params.setCompanyId(getCompanyId());
         List<CompanyMiniapp> miniApps = companyMiniappService.selectCompanyMiniappList(params);
-        if (miniApps != null) {
-            appId = miniApps.stream().min(Comparator.comparing(CompanyMiniapp::getSortNum)).map(CompanyMiniapp::getAppId).orElse(appId);
+        if (CloudHostUtils.hasCloudHostName("叮当国医")) {
+            return R.ok().put("data", miniApps);
+        } else {
+            if (miniApps != null) {
+                appId = miniApps.stream().min(Comparator.comparing(CompanyMiniapp::getSortNum)).map(CompanyMiniapp::getAppId).orElse(appId);
+            }
+            return R.ok().put("data", appId);
         }
-
-        return R.ok().put("data", appId);
     }
     @Login
     @ApiOperation("课程视频详情")
@@ -350,6 +363,31 @@ public class FsUserCourseVideoController extends AppBaseController {
         }
         return ResponseResult.ok(courseLinkService.getGotoWxAppLink(linkStr,appid));
     }
+
+    /**
+     * 获取跳转微信小程序的链接地址 新版 三个链接
+     */
+    @Login
+    @GetMapping("/getGotoWxAppLinkNew")
+    @ApiOperation("获取跳转微信小程序的链接地址")
+    public ResponseResult<String> getGotoWxAppLinkNew(String linkStr,String appid,Integer type) {
+        try {
+            String result = courseLinkService.getGotoWxAppLinkNew(linkStr,appid,type);
+            // 检查返回结果是否为空或空白
+            if (result == null || result.trim().isEmpty()) {
+                return ResponseResult.fail(500, "生成微信小程序链接失败");
+            }
+            // 检查返回结果是否包含错误信息
+            if (result.contains("错误") || result.contains("失败")) {
+                return ResponseResult.fail(500, result);
+            }
+            return ResponseResult.ok(result);
+        } catch (Exception e) {
+            log.error("获取跳转微信小程序链接失败", e);
+            return handleWechatError(e);
+        }
+    }
+
     /**
      * 获取跳转微信小程序的链接地址
      */
@@ -360,6 +398,49 @@ public class FsUserCourseVideoController extends AppBaseController {
         return ResponseResult.ok(liveService.getGotoWxAppLiveLink(linkStr,appid));
     }
 
+    /**
+     * 统一处理微信错误
+     */
+    private ResponseResult<String> handleWechatError(Exception e) {
+        // 1. 检查是否是WxErrorException(可能在cause中)
+        Throwable cause = e.getCause();
+        if (cause instanceof WxErrorException) {
+            WxErrorException wxError = (WxErrorException) cause;
+            Integer errcode = wxError.getError().getErrorCode();
+            String errmsg = wxError.getError().getErrorMsg();
+            log.error("微信API异常,错误码:{},错误信息:{}", errcode, errmsg);
+
+            String friendlyMsg = WechatErrorUtil.getFriendlyMessage(errcode);
+            Map<String, Object> extData = new HashMap<>();
+            extData.put("wechatErrorCode", errcode);
+            return new ResponseResult<>(500, friendlyMsg, null, extData);
+        }
+
+        // 2. 检查是否是微信错误(格式:微信错误:errcode|errmsg)
+        String errorMsg = e.getMessage();
+        if (errorMsg != null && errorMsg.startsWith("微信错误:")) {
+            String errorInfo = errorMsg.substring("微信错误:".length());
+            String[] parts = errorInfo.split("\\|", 2);
+            if (parts.length == 2) {
+                try {
+                    Integer errcode = Integer.parseInt(parts[0]);
+                    String errmsg = parts[1];
+                    log.error("微信返回错误,错误码:{},错误信息:{}", errcode, errmsg);
+
+                    String friendlyMsg = WechatErrorUtil.getFriendlyMessage(errcode);
+                    Map<String, Object> extData = new HashMap<>();
+                    extData.put("wechatErrorCode", errcode);
+                    return new ResponseResult<>(500, friendlyMsg, null, extData);
+                } catch (NumberFormatException ex) {
+                    // 格式错误,返回原始错误
+                }
+            }
+        }
+
+        // 3. 其他错误,返回通用错误信息
+        return ResponseResult.fail(500, "获取微信小程序链接失败:" + errorMsg);
+    }
+
     @ApiOperation("会员批量发送课程消息 发课")
     @PostMapping("/batchSendCourse")
     public OpenImResponseDTO batchSendCourse(@RequestBody BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException, UnsupportedEncodingException {
@@ -455,4 +536,45 @@ public class FsUserCourseVideoController extends AppBaseController {
         return imMsgSendLogService.deleteFsImMsgSendLogAndDetail(logId);
     }
 
+    @Login
+    @ApiOperation("获取飞书看课链接")
+    @GetMapping("/getFeiShuCourseLink")
+    public R getFeiShuCourseLink(@RequestParam Long companyUserId,
+                                 @RequestParam Long videoId,
+                                 @RequestParam Long periodId,
+                                 @RequestParam(required = false) Long companyId,
+                                 @RequestParam(required = false) Long courseId,
+                                 @RequestParam(required = false) Long projectId,
+                                 @RequestParam(required = false) Long id) {
+        // 读取配置判断是否启用飞书新跳转
+        Boolean enableFeishuNewLink = null;
+        try {
+            String json = configService.selectConfigByKey("course.config");
+            if (StringUtils.isNotBlank(json)) {
+                com.fs.course.config.CourseConfig config = cn.hutool.json.JSONUtil.toBean(json, com.fs.course.config.CourseConfig.class);
+                enableFeishuNewLink = config.getEnableFeishuNewLink();
+            }
+        } catch (Exception e) {
+            log.warn("读取飞书新跳转配置失败,使用老方法", e);
+        }
+
+        if (Boolean.TRUE.equals(enableFeishuNewLink)) {
+            FeishuLinkParam param = new FeishuLinkParam();
+            param.setCompanyId(companyId);
+            param.setCompanyUserId(companyUserId);
+            param.setCourseId(courseId);
+            param.setVideoId(videoId);
+            param.setPeriodId(periodId);
+            param.setProjectId(projectId);
+            param.setId(id);
+            String link = feiShuService.getFeishuRegisterLink(param);
+            log.info("链接{}", link);
+            return R.ok().put("data", link);
+        }
+        // 默认使用老方法
+        String oldLink = feiShuService.getFeishuCourseLink(companyUserId, videoId, periodId);
+        log.info("老链接{}", oldLink);
+        return R.ok().put("data", oldLink);
+    }
+
 }

+ 6 - 0
fs-service/pom.xml

@@ -337,6 +337,12 @@
             <version>1.70</version>
         </dependency>
 
+        <!-- 飞书SDK -->
+        <dependency>
+            <groupId>com.larksuite.oapi</groupId>
+            <artifactId>oapi-sdk</artifactId>
+            <version>2.5.3</version>
+        </dependency>
 
     </dependencies>
 

+ 3 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -26,6 +26,9 @@ public class CourseConfig implements Serializable {
     private String salesEaseCourseDomain; //销售易看课域名
     private String realLinkH5DomainName;//H5通用看课域名
     private String realLinkH5LiveName;//H5通用直播域名
+    private String feishuLinkDomainName;//飞书看课域名
+    private String feishuNewLinkDomainName;//新飞书看课域名
+    private Boolean enableFeishuNewLink;//是否启用飞书新跳转
     private String authDomainName;//网页授权域名
     private String smsDomainName;//短信推送域名
     private String smsAcquisitionName;//短信推送域名

+ 9 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseLinkService.java

@@ -104,6 +104,15 @@ public interface IFsCourseLinkService
      */
     String getGotoWxAppLink(String linkStr,String appid);
 
+    /**
+     * 获取跳转微信小程序的链接地址(合并新旧方法)
+     * @param linkStr 链接字符串
+     * @param appId 小程序appId
+     * @param type 1-新链接(generatescheme) 2-老链接(generate_urllink)
+     * @return
+     */
+    String getGotoWxAppLinkNew(String linkStr, String appId, Integer type);
+
     /**
      * 获取跳转微信小程序的链接地址 获取ShortLink
      */

+ 107 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java

@@ -964,6 +964,113 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
         return "";
     }
 
+    @Override
+    public String getGotoWxAppLinkNew(String linkStr, String appId, Integer type) {
+        CloseableHttpClient client = null;
+        try {
+            client = HttpClients.createDefault();
+            String[] split = linkStr.split("\\?.*?=");
+            String key = linkStr.replaceAll(".*\\?(.*?)=.*", "$1");
+            if (split.length == 2 && split[0].length() > 0 && split[1].length() > 0) {
+                //处理页面路径
+                String pageUrl = split[0];
+                if (pageUrl.startsWith("/")) {
+                    pageUrl = pageUrl.substring(1);
+                }
+                //处理参数
+                String query = split[1];
+                query = key + "=" + URLEncoder.encode(query, StandardCharsets.UTF_8.toString());
+                //获取微信token
+                final WxMaService wxService = WxMaConfiguration.getMaService(appId);
+                String token = wxService.getAccessToken();
+                log.info("小程序TOKEN值-------->刷新前TOKEN:{}", token);
+
+                JSONObject bodyObj = new JSONObject();
+                String apiUrl;
+                String responseField;
+                if ( type == null || type == 3) {
+                    // 新链接:generatescheme
+                    apiUrl = "https://api.weixin.qq.com/wxa/generatescheme?access_token=" + token;
+                    JSONObject jumpWxa = new JSONObject();
+                    jumpWxa.put("path", pageUrl);
+                    jumpWxa.put("query", query);
+                    jumpWxa.put("env_version", "release");
+                    bodyObj.put("jump_wxa", jumpWxa);
+                    bodyObj.put("is_expire", false);
+                    bodyObj.put("expire_type", 1);
+                    bodyObj.put("expire_interval", 30);
+                    responseField = "openlink";
+                } else {
+                    // 老链接:generate_urllink
+                    apiUrl = "https://api.weixin.qq.com/wxa/generate_urllink?access_token=" + token;
+                    bodyObj.put("path", pageUrl);
+                    bodyObj.put("query", query);
+                    responseField = "url_link";
+                }
+
+                HttpPost httpPost = new HttpPost(apiUrl);
+                log.info("微信小程序请求参数打印:{}", bodyObj.toJSONString());
+                StringEntity entity = new StringEntity(bodyObj.toJSONString(), "UTF-8");
+                httpPost.setEntity(entity);
+                httpPost.setHeader("Content-type", "application/json");
+                httpPost.setHeader("cache-control", "max-age=0");
+                HttpEntity response = client.execute(httpPost).getEntity();
+                String responseString = EntityUtils.toString(response);
+                log.info("微信小程序接口响应数据:{}", responseString);
+                JSONObject jsonObject = JSONObject.parseObject(responseString);
+
+                if (TOKEN_VALID_CODE.equals(jsonObject.getString("errcode"))) {
+                    Integer curVersion = Integer.valueOf(version);
+                    synchronized (TOKEN_VALID_CODE) {
+                        if (curVersion.equals(version)) {
+                            log.info("小程序TOKEN:40001进入强制刷新-------->刷新前TOKEN:{}", token);
+                            wxService.getAccessToken(true);
+                            version = version.equals(Integer.MAX_VALUE) ? 0 : curVersion + 1;
+                            log.info("小程序TOKEN:40001进入强制刷新-------->刷新后TOKEN:{}", wxService.getAccessToken());
+                        }
+                        return getGotoWxAppLinkNew(linkStr, appId, type);
+                    }
+                }
+
+                if (null != jsonObject && !jsonObject.isEmpty() && jsonObject.containsKey(responseField)) {
+                    String link = jsonObject.getString(responseField);
+                    // https://wxaurl.cn/HP0Hx7Fnjdm https://wxmpurl.cn/HP0Hx7Fnjdm
+                    if(type!=null && type==1){
+                        link = link.replace("wxmpurl.cn", "wxaurl.cn");
+                    }
+                    if(type!=null && type==2){
+                        link = link.replace("wxaurl.cn", "wxmpurl.cn");
+                    }
+                    return link;
+                }
+
+                // 处理微信错误响应
+                if (jsonObject != null && jsonObject.containsKey("errcode")) {
+                    Integer errcode = jsonObject.getInteger("errcode");
+                    String errmsg = jsonObject.getString("errmsg");
+                    log.error("微信小程序生成链接失败,错误码:{},错误信息:{}", errcode, errmsg);
+                    throw new RuntimeException("微信错误:" + errcode + "|" + errmsg);
+                }
+
+                // 响应格式异常
+                log.error("微信小程序响应格式异常");
+                throw new RuntimeException("微信小程序响应格式异常");
+            } else {
+                return "页面链接错误,获取失败";
+            }
+
+        } catch (WxErrorException e) {
+            log.error("微信API调用异常", e);
+            throw new RuntimeException("微信API异常", e);
+        } catch (ClientProtocolException e) {
+            log.error("HTTP协议错误", e);
+            throw new RuntimeException("网络请求失败", e);
+        } catch (IOException e) {
+            log.error("IO异常", e);
+            throw new RuntimeException("网络连接失败", e);
+        }
+    }
+
     @Override
     public R getWxaCodeGenerateScheme(String linkStr,String appId) {
         CloseableHttpClient client = null;

+ 125 - 0
fs-service/src/main/java/com/fs/course/utils/WechatErrorUtil.java

@@ -0,0 +1,125 @@
+package com.fs.course.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 微信错误处理工具类
+ *
+ * @author system
+ */
+@Slf4j
+public class WechatErrorUtil {
+
+    /**
+     * 格式化错误信息(格式:错误码|错误信息)
+     */
+    public static String formatError(Integer errcode, String msg) {
+        return errcode + "|" + msg;
+    }
+
+    /**
+     * 根据微信错误码返回友好的错误提示
+     */
+    public static String getFriendlyMessage(Integer errcode) {
+        if (errcode == null) {
+            return "微信接口调用失败";
+        }
+
+        switch (errcode) {
+            case 40001:
+                return "微信access_token无效或已过期";
+            case 40002:
+                return "微信access_token为空";
+            case 40013:
+                return "微信小程序AppID无效";
+            case 40165:
+                return "微信小程序页面路径不存在";
+            case 45009:
+                return "微信小程序接口调用频率超限,请稍后重试";
+            case 47003:
+                return "微信小程序模板消息参数错误";
+            case 50002:
+                return "该小程序已被封禁或暂停服务";
+            case 50003:
+                return "微信小程序未发布,无法生成链接";
+            case 85079:
+                return "微信小程序程序代码待发布,请先发布版本";
+            case 85301:
+                return "微信小程序审核未通过";
+            case 89449:
+                return "微信小程序流量主开通失败";
+            default:
+                return "微信接口调用失败";
+        }
+    }
+
+    /**
+     * 处理WxErrorException异常
+     *
+     * @param e WxErrorException异常
+     * @param defaultMessage 默认错误信息
+     * @return 格式化后的错误信息(错误码|错误信息)
+     */
+    public static String handleWxErrorException(WxErrorException e, String defaultMessage) {
+        log.error("微信API调用异常", e);
+
+        // WxErrorException 包含微信返回的错误信息
+        Integer errcode = e.getError().getErrorCode();
+        String errmsg = e.getError().getErrorMsg();
+
+        // 如果有微信错误码,使用微信的错误处理逻辑
+        if (errcode != null) {
+            String friendlyMsg = getFriendlyMessage(errcode);
+            log.error("微信返回错误,错误码:{},原始信息:{},友好提示:{}", errcode, errmsg, friendlyMsg);
+            return formatError(errcode, friendlyMsg);
+        }
+
+        // 否则返回默认错误
+        return formatError(-2, defaultMessage);
+    }
+
+    /**
+     * 解析错误信息,返回包含错误码和错误信息的Map
+     *
+     * @param errorMsg 错误信息(格式:错误码|错误信息)
+     * @return Map包含errcode和msg,如果格式不正确返回null
+     */
+    public static Map<String, Object> parseError(String errorMsg) {
+        if (errorMsg == null || !errorMsg.contains("|")) {
+            return null;
+        }
+
+        String[] parts = errorMsg.split("\\|", 2);
+        if (parts.length != 2) {
+            return null;
+        }
+
+        try {
+            Integer errcode = Integer.parseInt(parts[0]);
+            Map<String, Object> result = new HashMap<>();
+            result.put("errcode", errcode);
+            result.put("msg", parts[1]);
+            return result;
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 系统错误码定义
+     */
+    public static class SystemErrorCode {
+        /** 响应格式异常 */
+        public static final int RESPONSE_FORMAT_ERROR = -1;
+        /** access_token获取失败 */
+        public static final int ACCESS_TOKEN_ERROR = -2;
+        /** 网络请求失败 */
+        public static final int NETWORK_REQUEST_ERROR = -3;
+        /** 网络连接失败 */
+        public static final int NETWORK_CONNECTION_ERROR = -4;
+    }
+}

+ 13 - 0
fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java

@@ -0,0 +1,13 @@
+package com.fs.feishu.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Data
+@Configuration
+@ConfigurationProperties("feishu")
+public class FeiShuConfig {
+    private String appId;
+    private String appSecret;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/feishu/domain/FsAuthLinkToken.java

@@ -0,0 +1,26 @@
+package com.fs.feishu.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 飞书授权链接短token映射
+ * 用于替代明文JSON参数,避免微信管控
+ */
+@Data
+public class FsAuthLinkToken implements Serializable {
+
+    /** 短token(主键) */
+    private String token;
+
+    /** 授权参数JSON */
+    private String paramsJson;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 过期时间 */
+    private Date expireTime;
+}

+ 21 - 0
fs-service/src/main/java/com/fs/feishu/mapper/FsAuthLinkTokenMapper.java

@@ -0,0 +1,21 @@
+package com.fs.feishu.mapper;
+
+import com.fs.feishu.domain.FsAuthLinkToken;
+
+/**
+ * 飞书授权链接token Mapper
+ */
+public interface FsAuthLinkTokenMapper {
+
+    /** 新增token记录 */
+    int insertFsAuthLinkToken(FsAuthLinkToken fsAuthLinkToken);
+
+    /** 根据token查询 */
+    FsAuthLinkToken selectFsAuthLinkTokenByToken(String token);
+
+    /** 根据token删除 */
+    int deleteFsAuthLinkTokenByToken(String token);
+
+    /** 删除已过期的token记录 */
+    int deleteExpiredTokens();
+}

+ 16 - 0
fs-service/src/main/java/com/fs/feishu/model/FeishuFileDTO.java

@@ -0,0 +1,16 @@
+package com.fs.feishu.model;
+
+import lombok.Data;
+
+@Data
+public class FeishuFileDTO {
+
+    private String name;
+    private String token;
+    private String type;
+    private String url;
+    private String ownerId;
+    private String parentToken;
+    private String createdTime;
+    private String modifiedTime;
+}

+ 38 - 0
fs-service/src/main/java/com/fs/feishu/model/FeishuLinkParam.java

@@ -0,0 +1,38 @@
+package com.fs.feishu.model;
+
+import lombok.Data;
+
+
+import java.io.Serializable;
+
+/**
+ * 飞书链接参数
+ */
+@Data
+public class FeishuLinkParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 公司用户ID */
+    private Long companyUserId;
+
+    /** 课程ID */
+    private Long courseId;
+
+    /** 视频ID */
+    private Long videoId;
+
+    /** 期数ID */
+    private Long periodId;
+
+    /** 项目ID */
+    private Long projectId;
+
+    private  Long userId;
+
+    private  Long id;
+
+}

+ 435 - 0
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -0,0 +1,435 @@
+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;
+    @Autowired
+    private IFsAuthLinkTokenService authLinkTokenService;
+
+    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.getPeriodId(), 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 + "/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(生成短token,参数存Redis+数据库,适配飞书iframe,双重URL编码)
+     */
+    private String buildAuthLink(Long companyId, Long companyUserId, Long courseId, Long videoId, Long periodId, Long projectId,Long id) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        // 授权参数:生成短token,参数存Redis+数据库
+        Map<String, Object> authParam = new LinkedHashMap<>();
+        authParam.put("periodId", periodId);
+        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 token = authLinkTokenService.saveAuthParams(authParam);
+
+        try {
+            String baseUrl = config.getFeishuNewLinkDomainName();
+            String AUTH_PATH ="/pages/wxauth";
+            String fullUrl = baseUrl + AUTH_PATH + "?t=" + token;
+            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. 创建时间在两天 之前的执行删除
+     */
+    public void deleteFilesBeforeToday() throws Exception {
+        // 今天 00:00:00 的时间戳(秒)
+        Calendar twoDaysAgo  = Calendar.getInstance();
+        twoDaysAgo .set(Calendar.HOUR_OF_DAY, 0);
+        twoDaysAgo .set(Calendar.MINUTE, 0);
+        twoDaysAgo .set(Calendar.SECOND, 0);
+        twoDaysAgo .set(Calendar.MILLISECOND, 0);
+        // 减去两天
+        twoDaysAgo.add(Calendar.DAY_OF_MONTH, -2);
+        long todayStartSec = twoDaysAgo .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);
+    }
+}

+ 34 - 0
fs-service/src/main/java/com/fs/feishu/service/IFsAuthLinkTokenService.java

@@ -0,0 +1,34 @@
+package com.fs.feishu.service;
+
+import java.util.Map;
+
+/**
+ * 飞书授权链接token Service
+ * Redis + 数据库双写,查询优先 Redis,miss 回源数据库并回填
+ */
+public interface IFsAuthLinkTokenService {
+
+    /**
+     * 生成短token并保存授权参数(Redis + 数据库双写)
+     *
+     * @param params 授权参数
+     * @return 短token
+     */
+    String saveAuthParams(Map<String, Object> params);
+
+    /**
+     * 根据token查询授权参数
+     * 优先查 Redis,miss 则查数据库并回填 Redis
+     *
+     * @param token 短token
+     * @return 授权参数,token无效或过期返回 null
+     */
+    Map<String, Object> getAuthParamsByToken(String token);
+
+    /**
+     * 清理已过期的token记录
+     *
+     * @return 删除条数
+     */
+    int cleanExpiredTokens();
+}

+ 107 - 0
fs-service/src/main/java/com/fs/feishu/service/impl/FsAuthLinkTokenServiceImpl.java

@@ -0,0 +1,107 @@
+package com.fs.feishu.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.feishu.domain.FsAuthLinkToken;
+import com.fs.feishu.mapper.FsAuthLinkTokenMapper;
+import com.fs.feishu.service.IFsAuthLinkTokenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.security.SecureRandom;
+import java.util.Date;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 飞书授权链接token Service实现
+ * Redis主存(1天过期)+ 数据库持久化双写
+ */
+@Slf4j
+@Service
+public class FsAuthLinkTokenServiceImpl implements IFsAuthLinkTokenService {
+
+    private static final String REDIS_KEY_PREFIX = "feishu:auth:token:";
+    private static final int TOKEN_LENGTH = 8;
+    private static final int EXPIRE_HOURS = 24;
+    private static final String TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
+
+    @Autowired
+    private FsAuthLinkTokenMapper fsAuthLinkTokenMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public String saveAuthParams(Map<String, Object> params) {
+        String token = generateToken();
+        String paramsJson = JSONUtil.toJsonStr(params);
+
+        // Redis 主存(1天过期)
+        String redisKey = REDIS_KEY_PREFIX + token;
+        redisCache.setCacheObject(redisKey, paramsJson, EXPIRE_HOURS, TimeUnit.HOURS);
+
+        // 数据库持久化
+        Date now = new Date();
+        FsAuthLinkToken record = new FsAuthLinkToken();
+        record.setToken(token);
+        record.setParamsJson(paramsJson);
+        record.setCreateTime(now);
+        record.setExpireTime(new Date(now.getTime() + (long) EXPIRE_HOURS * 3600 * 1000));
+        try {
+            fsAuthLinkTokenMapper.insertFsAuthLinkToken(record);
+        } catch (Exception e) {
+            log.error("授权token入库失败,token={},仅Redis可用", token, e);
+        }
+
+        return token;
+    }
+
+    @Override
+    public Map<String, Object> getAuthParamsByToken(String token) {
+        if (token == null || token.isEmpty()) {
+            return null;
+        }
+
+        // 优先查 Redis
+        String redisKey = REDIS_KEY_PREFIX + token;
+        String paramsJson = redisCache.getCacheObject(redisKey);
+        if (paramsJson == null) {
+            // Redis miss,回源数据库
+            FsAuthLinkToken record = fsAuthLinkTokenMapper.selectFsAuthLinkTokenByToken(token);
+            if (record == null) {
+                return null;
+            }
+            // 校验数据库过期时间
+            if (record.getExpireTime() != null && record.getExpireTime().before(new Date())) {
+                return null;
+            }
+            paramsJson = record.getParamsJson();
+            // 回填 Redis(剩余有效时长,秒为单位)
+            long remainMs = record.getExpireTime() != null
+                    ? record.getExpireTime().getTime() - System.currentTimeMillis() : 0;
+            int remainSeconds = (int) (remainMs / 1000);
+            if (remainSeconds > 0) {
+                redisCache.setCacheObject(redisKey, paramsJson, remainSeconds, TimeUnit.SECONDS);
+            }
+        }
+
+        return JSONUtil.toBean(paramsJson, Map.class);
+    }
+
+    @Override
+    public int cleanExpiredTokens() {
+        return fsAuthLinkTokenMapper.deleteExpiredTokens();
+    }
+
+    /** 生成8位随机token(小写字母+数字) */
+    private String generateToken() {
+        SecureRandom random = new SecureRandom();
+        StringBuilder sb = new StringBuilder(TOKEN_LENGTH);
+        for (int i = 0; i < TOKEN_LENGTH; i++) {
+            sb.append(TOKEN_CHARS.charAt(random.nextInt(TOKEN_CHARS.length())));
+        }
+        return sb.toString();
+    }
+}

+ 103 - 0
fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java

@@ -0,0 +1,103 @@
+package com.fs.feishu.util;
+
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+
+public class SecureTokenUtil {
+    
+    private static final String ALGORITHM = "AES";
+    private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding";
+    private static final String SECRET_KEY = "9f3a7c21b8e4d6a5c2f1097e3b4a6d8f"; // 16字节密钥
+    
+    /**
+     * 生成安全令牌
+     */
+    public static String generateToken(Long companyUserId, Long videoId, Long periodId, Long timestamp) {
+        try {
+            String dataBuilder = companyUserId + ":" + videoId + ":" + periodId + ":" + timestamp;
+            return encryptData(dataBuilder);
+        } catch (Exception e) {
+            throw new RuntimeException("飞书令牌生成失败", e);
+        }
+    }
+
+    /**
+     * 生成安全令牌
+     */
+    public static String generateTokenUser(Long companyUserId, Long videoId, Long periodId, Long timestamp,Long userId) {
+        try {
+            String dataBuilder = companyUserId + ":" + videoId + ":" + periodId + ":"+userId+":" + timestamp;
+            return encryptData(dataBuilder);
+        } catch (Exception e) {
+            throw new RuntimeException("飞书令牌生成失败", e);
+        }
+    }
+
+    /**
+     * 加密数据
+     */
+    private static String encryptData(String data) throws Exception {
+        SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
+        byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted);
+    }
+    
+    /**
+     * 解析安全令牌(兼容4字段和5字段)
+     */
+    public static Map<String, Object> parseToken(String token) {
+        try {
+            String decryptedData = decryptData(token);
+            String[] parts = decryptedData.split(":");
+            
+            Map<String, Object> result = new HashMap<>();
+            result.put("companyUserId", Long.parseLong(parts[0]));
+            result.put("videoId", Long.parseLong(parts[1]));
+            result.put("periodId", Long.parseLong(parts[2]));
+            if (parts.length == 5) {
+                result.put("userId", Long.parseLong(parts[3]));
+                result.put("timestamp", Long.parseLong(parts[4]));
+            } else {
+                result.put("timestamp", Long.parseLong(parts[3]));
+            }
+
+            return result;
+        } catch (Exception e) {
+            throw new RuntimeException("飞书令牌解析失败", e);
+        }
+    }
+
+    /**
+     * 解密数据
+     */
+    private static String decryptData(String encryptedData) throws Exception {
+        SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+        cipher.init(Cipher.DECRYPT_MODE, keySpec);
+        byte[] decrypted = cipher.doFinal(Base64.getUrlDecoder().decode(encryptedData));
+        return new String(decrypted, StandardCharsets.UTF_8);
+    }
+    
+    /**
+     * 验证令牌时效性
+     */
+    public static boolean isTokenValid(Long timestamp) {
+        if (timestamp == null) {
+            return false;
+        }
+        long currentTime = System.currentTimeMillis() / 1000;
+        return (currentTime - timestamp) <= 60 * 60 * 24 * 2;
+    }
+
+    public static void main(String[] args) {
+        System.out.println(generateToken(10378L, 136L,38L, System.currentTimeMillis() / 1000));
+    }
+}

+ 5 - 0
fs-service/src/main/resources/application-druid-ddgy.yml

@@ -178,3 +178,8 @@ im:
 isNewWxMerchant: true
 
 enableRedPackAccount: 0
+
+# 飞书
+feishu:
+    appId:
+    appSecret:

+ 39 - 0
fs-service/src/main/resources/mapper/feishu/FsAuthLinkTokenMapper.xml

@@ -0,0 +1,39 @@
+<?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.fs.feishu.mapper.FsAuthLinkTokenMapper">
+
+    <resultMap id="FsAuthLinkTokenResult" type="com.fs.feishu.domain.FsAuthLinkToken">
+        <result property="token"       column="token"/>
+        <result property="paramsJson"  column="params_json"/>
+        <result property="createTime"  column="create_time"/>
+        <result property="expireTime"  column="expire_time"/>
+    </resultMap>
+
+    <sql id="selectFsAuthLinkTokenVo">
+        select token, params_json, create_time, expire_time
+        from fs_auth_link_token
+    </sql>
+
+    <insert id="insertFsAuthLinkToken" parameterType="com.fs.feishu.domain.FsAuthLinkToken">
+        insert into fs_auth_link_token
+        (token, params_json, create_time, expire_time)
+        values
+        (#{token}, #{paramsJson}, #{createTime}, #{expireTime})
+    </insert>
+
+    <select id="selectFsAuthLinkTokenByToken" parameterType="String" resultMap="FsAuthLinkTokenResult">
+        <include refid="selectFsAuthLinkTokenVo"/>
+        where token = #{token}
+    </select>
+
+    <delete id="deleteFsAuthLinkTokenByToken" parameterType="String">
+        delete from fs_auth_link_token where token = #{token}
+    </delete>
+
+    <delete id="deleteExpiredTokens">
+        delete from fs_auth_link_token where expire_time &lt; NOW()
+    </delete>
+
+</mapper>