yumeng 6 місяців тому
батько
коміт
426cb5dd03
16 змінених файлів з 1852 додано та 0 видалено
  1. 180 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/config/ChangduSeriesFeishuLegacyWsBridge.java
  2. 92 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/config/ChangduSeriesFeishuProperties.java
  3. 53 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/config/ChangduSeriesFeishuWsConfig.java
  4. 88 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/entity/ChangduSeriesCardCandidate.java
  5. 46 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/entity/ChangduSeriesSendResult.java
  6. 39 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/mapper/ChangduSeriesFeishuMapper.java
  7. 102 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/mapper/xml/ChangduSeriesFeishuMapper.xml
  8. 11 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/service/IChangduSeriesFeishuService.java
  9. 405 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/service/impl/ChangduSeriesFeishuServiceImpl.java
  10. 289 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/support/ChangduSeriesCardBuilder.java
  11. 38 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/support/ChangduSeriesCardConstants.java
  12. 281 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/support/ChangduSeriesFeishuClient.java
  13. 166 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/controller/ChangduSeriesFeishuDebugController.java
  14. 26 0
      job-bytedance/src/main/java/cn/com/ctop/job/bytedance/handler/ChangduSeriesFeishuJob.java
  15. 29 0
      job-bytedance/src/main/resources/changdu-series-feishu.sql
  16. 7 0
      job-bytedance/src/main/resources/changdu-series.properties

+ 180 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/config/ChangduSeriesFeishuLegacyWsBridge.java

@@ -0,0 +1,180 @@
+package cn.com.ctop.job.bytedance.changdu.config;
+
+import cn.com.ctop.job.bytedance.changdu.service.IChangduSeriesFeishuService;
+import cn.com.ctop.job.bytedance.config.FeishuRobotLinkConfig;
+import com.lark.oapi.event.EventDispatcher;
+import com.lark.oapi.event.ICallBackHandler;
+import com.lark.oapi.event.cardcallback.P2CardActionTriggerHandler;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTrigger;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.SmartInitializingSingleton;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Configuration;
+
+import java.lang.reflect.Field;
+import java.util.LinkedHashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+@Configuration
+public class ChangduSeriesFeishuLegacyWsBridge implements SmartInitializingSingleton {
+
+    private static final Logger log = LoggerFactory.getLogger(ChangduSeriesFeishuLegacyWsBridge.class);
+    private static final String LEGACY_FS_CLIENT_BEAN = "fsClient";
+    private static final String CARD_ACTION_TRIGGER = "card.action.trigger";
+    private static final String CLIENT_EVENT_HANDLER_FIELD = "eventHandler";
+    private static final String LEGACY_CONFIG_EVENT_HANDLER_FIELD = "EVENT_HANDLER";
+
+    private final ApplicationContext applicationContext;
+    private final IChangduSeriesFeishuService changduSeriesFeishuService;
+
+    private volatile boolean legacyConfigHandlerReady;
+    private volatile boolean legacyClientHandlerReady;
+    private volatile int wsClientHandlerReadyCount;
+
+    public ChangduSeriesFeishuLegacyWsBridge(ApplicationContext applicationContext,
+                                             IChangduSeriesFeishuService changduSeriesFeishuService) {
+        this.applicationContext = applicationContext;
+        this.changduSeriesFeishuService = changduSeriesFeishuService;
+    }
+
+    @Override
+    public void afterSingletonsInstantiated() {
+        legacyConfigHandlerReady = registerOnLegacyConfig();
+        legacyClientHandlerReady = registerOnNamedClient(LEGACY_FS_CLIENT_BEAN);
+        wsClientHandlerReadyCount = registerOnAllWsClients();
+        log.info("changdu legacy ws bridge ready, legacyConfigHandlerReady={}, legacyClientHandlerReady={}, wsClientHandlerReadyCount={}",
+                legacyConfigHandlerReady, legacyClientHandlerReady, wsClientHandlerReadyCount);
+    }
+
+    public Map<String, Object> getHandlerStatus() {
+        Map<String, Object> status = new LinkedHashMap<String, Object>();
+        status.put("legacyConfigHandlerReady", legacyConfigHandlerReady);
+        status.put("legacyClientHandlerReady", legacyClientHandlerReady);
+        status.put("wsClientHandlerReadyCount", wsClientHandlerReadyCount);
+        status.put("hasLegacyFsClientBean", applicationContext.containsBean(LEGACY_FS_CLIENT_BEAN));
+        status.put("wsClientBeans", applicationContext.getBeansOfType(com.lark.oapi.ws.Client.class).keySet());
+        return status;
+    }
+
+    private boolean registerOnLegacyConfig() {
+        Map<String, FeishuRobotLinkConfig> configBeans = applicationContext.getBeansOfType(FeishuRobotLinkConfig.class);
+        if (configBeans == null || configBeans.isEmpty()) {
+            return false;
+        }
+        boolean registered = false;
+        for (Map.Entry<String, FeishuRobotLinkConfig> entry : configBeans.entrySet()) {
+            EventDispatcher dispatcher = extractEventDispatcherFromConfig(entry.getValue());
+            if (dispatcher == null) {
+                continue;
+            }
+            registered = registerOnDispatcher(dispatcher, "configBean=" + entry.getKey()) || registered;
+        }
+        return registered;
+    }
+
+    private boolean registerOnNamedClient(String beanName) {
+        if (!applicationContext.containsBean(beanName)) {
+            return false;
+        }
+        return registerOnClient(beanName, applicationContext.getBean(beanName));
+    }
+
+    private int registerOnAllWsClients() {
+        Map<String, com.lark.oapi.ws.Client> clientBeans = applicationContext.getBeansOfType(com.lark.oapi.ws.Client.class);
+        if (clientBeans == null || clientBeans.isEmpty()) {
+            return 0;
+        }
+        int count = 0;
+        Set<Integer> seenDispatcher = new HashSet<Integer>();
+        for (Map.Entry<String, com.lark.oapi.ws.Client> entry : clientBeans.entrySet()) {
+            EventDispatcher dispatcher = extractEventDispatcher(entry.getValue());
+            if (dispatcher == null) {
+                continue;
+            }
+            int identity = System.identityHashCode(dispatcher);
+            if (!seenDispatcher.add(identity)) {
+                continue;
+            }
+            if (registerOnDispatcher(dispatcher, "wsClientBean=" + entry.getKey())) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    private boolean registerOnClient(String sourceName, Object clientBean) {
+        EventDispatcher dispatcher = extractEventDispatcher(clientBean);
+        if (dispatcher == null) {
+            log.warn("changdu legacy ws bridge skipped, eventDispatcher not found on {}", sourceName);
+            return false;
+        }
+        return registerOnDispatcher(dispatcher, sourceName);
+    }
+
+    private boolean registerOnDispatcher(EventDispatcher dispatcher, String source) {
+        try {
+            Map<String, ICallBackHandler> callbackHandlers = extractCallbackHandlers(dispatcher);
+            if (callbackHandlers == null) {
+                log.warn("changdu legacy ws bridge skipped, callback handler map not found, source={}", source);
+                return false;
+            }
+            if (callbackHandlers.containsKey(CARD_ACTION_TRIGGER)) {
+                log.info("changdu legacy ws bridge detected existing card.action.trigger handler, source={}", source);
+                return true;
+            }
+            callbackHandlers.put(CARD_ACTION_TRIGGER, new P2CardActionTriggerHandler() {
+                @Override
+                public P2CardActionTriggerResponse handle(P2CardActionTrigger event) {
+                    return changduSeriesFeishuService.handleCardAction(event);
+                }
+            });
+            log.info("changdu legacy ws bridge registered card.action.trigger handler, source={}", source);
+            return true;
+        } catch (Exception exception) {
+            log.error("changdu legacy ws bridge register handler failed, source={}", source, exception);
+            return false;
+        }
+    }
+
+    private EventDispatcher extractEventDispatcherFromConfig(FeishuRobotLinkConfig configBean) {
+        if (configBean == null) {
+            return null;
+        }
+        try {
+            Field field = FeishuRobotLinkConfig.class.getDeclaredField(LEGACY_CONFIG_EVENT_HANDLER_FIELD);
+            field.setAccessible(true);
+            Object value = field.get(configBean);
+            return value instanceof EventDispatcher ? (EventDispatcher) value : null;
+        } catch (Exception exception) {
+            log.warn("changdu legacy ws bridge extract config dispatcher failed", exception);
+            return null;
+        }
+    }
+
+    private EventDispatcher extractEventDispatcher(Object clientBean) {
+        if (clientBean == null) {
+            return null;
+        }
+        try {
+            Field field = clientBean.getClass().getDeclaredField(CLIENT_EVENT_HANDLER_FIELD);
+            field.setAccessible(true);
+            Object value = field.get(clientBean);
+            return value instanceof EventDispatcher ? (EventDispatcher) value : null;
+        } catch (Exception exception) {
+            log.warn("changdu legacy ws bridge extract client dispatcher failed, beanClass={}", clientBean.getClass().getName(), exception);
+            return null;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, ICallBackHandler> extractCallbackHandlers(EventDispatcher dispatcher) throws NoSuchFieldException, IllegalAccessException {
+        Field field = EventDispatcher.class.getDeclaredField("eventType2CardCallBackHandler");
+        field.setAccessible(true);
+        Object value = field.get(dispatcher);
+        return value instanceof Map ? (Map<String, ICallBackHandler>) value : null;
+    }
+}

+ 92 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/config/ChangduSeriesFeishuProperties.java

@@ -0,0 +1,92 @@
+package cn.com.ctop.job.bytedance.changdu.config;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
+
+@Configuration
+@PropertySource(value = "classpath:changdu-series.properties", encoding = "UTF-8")
+@ConfigurationProperties(prefix = "changdu.series.feishu")
+public class ChangduSeriesFeishuProperties {
+
+    private String appId;
+    private String appSecret;
+    private String receiveType = "chat_id";
+    private String receiveId;
+    private Integer batchSize = 100;
+    private Integer retryLimit = 3;
+    private Integer processingTimeoutMinutes = 30;
+
+    public String getAppId() {
+        return appId;
+    }
+
+    public void setAppId(String appId) {
+        this.appId = appId;
+    }
+
+    public String getAppSecret() {
+        return appSecret;
+    }
+
+    public void setAppSecret(String appSecret) {
+        this.appSecret = appSecret;
+    }
+
+    public String getReceiveType() {
+        return receiveType;
+    }
+
+    public void setReceiveType(String receiveType) {
+        this.receiveType = receiveType;
+    }
+
+    public String getReceiveId() {
+        return receiveId;
+    }
+
+    public void setReceiveId(String receiveId) {
+        this.receiveId = receiveId;
+    }
+
+    public Integer getBatchSize() {
+        return batchSize;
+    }
+
+    public void setBatchSize(Integer batchSize) {
+        this.batchSize = batchSize;
+    }
+
+    public Integer getRetryLimit() {
+        return retryLimit;
+    }
+
+    public void setRetryLimit(Integer retryLimit) {
+        this.retryLimit = retryLimit;
+    }
+
+    public Integer getProcessingTimeoutMinutes() {
+        return processingTimeoutMinutes;
+    }
+
+    public void setProcessingTimeoutMinutes(Integer processingTimeoutMinutes) {
+        this.processingTimeoutMinutes = processingTimeoutMinutes;
+    }
+
+    public String getEffectiveAppId() {
+        return appId;
+    }
+
+    public String getEffectiveAppSecret() {
+        return appSecret;
+    }
+
+    public boolean hasAppCredential() {
+        return StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appSecret);
+    }
+
+    public boolean hasReceiveTarget() {
+        return StringUtils.isNotBlank(receiveType) && StringUtils.isNotBlank(receiveId);
+    }
+}

+ 53 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/config/ChangduSeriesFeishuWsConfig.java

@@ -0,0 +1,53 @@
+package cn.com.ctop.job.bytedance.changdu.config;
+
+import cn.com.ctop.job.bytedance.changdu.service.IChangduSeriesFeishuService;
+import cn.com.ctop.job.bytedance.utils.FeishuRobotUtil;
+import com.lark.oapi.event.EventDispatcher;
+import com.lark.oapi.event.cardcallback.P2CardActionTriggerHandler;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTrigger;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ChangduSeriesFeishuWsConfig {
+
+    private static final Logger log = LoggerFactory.getLogger(ChangduSeriesFeishuWsConfig.class);
+
+    private final ChangduSeriesFeishuProperties properties;
+    private final IChangduSeriesFeishuService changduSeriesFeishuService;
+
+    public ChangduSeriesFeishuWsConfig(ChangduSeriesFeishuProperties properties,
+                                       IChangduSeriesFeishuService changduSeriesFeishuService) {
+        this.properties = properties;
+        this.changduSeriesFeishuService = changduSeriesFeishuService;
+    }
+
+    @Bean(name = "changduSeriesFeishuWsClient")
+    @ConditionalOnExpression("'${changdu.series.feishu.app-id:}' != '' && '${changdu.series.feishu.app-secret:}' != '' && '${changdu.series.feishu.app-id:}' != '" + FeishuRobotUtil.fs_zt_appid + "'")
+    @ConditionalOnMissingBean(name = "fsClient")
+    public com.lark.oapi.ws.Client changduSeriesFeishuWsClient() {
+        com.lark.oapi.ws.Client client = new com.lark.oapi.ws.Client.Builder(
+                properties.getEffectiveAppId(),
+                properties.getEffectiveAppSecret()
+        ).eventHandler(buildEventHandler()).build();
+        client.start();
+        log.info("changdu series feishu ws client started, appId={}", properties.getEffectiveAppId());
+        return client;
+    }
+
+    private EventDispatcher buildEventHandler() {
+        return EventDispatcher.newBuilder("", "")
+                .onP2CardActionTrigger(new P2CardActionTriggerHandler() {
+                    @Override
+                    public P2CardActionTriggerResponse handle(P2CardActionTrigger event) {
+                        return changduSeriesFeishuService.handleCardAction(event);
+                    }
+                })
+                .build();
+    }
+}

+ 88 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/entity/ChangduSeriesCardCandidate.java

@@ -0,0 +1,88 @@
+package cn.com.ctop.job.bytedance.changdu.entity;
+
+import java.util.Date;
+
+public class ChangduSeriesCardCandidate {
+
+    private String bookId;
+    private String seriesName;
+    private String ossCover;
+    private Integer gender;
+    private String categoryText;
+    private Date estimatePublishTime;
+    private Date updatedAt;
+    private Integer sendStatus;
+    private Integer retryCount;
+
+    public String getBookId() {
+        return bookId;
+    }
+
+    public void setBookId(String bookId) {
+        this.bookId = bookId;
+    }
+
+    public String getSeriesName() {
+        return seriesName;
+    }
+
+    public void setSeriesName(String seriesName) {
+        this.seriesName = seriesName;
+    }
+
+    public String getOssCover() {
+        return ossCover;
+    }
+
+    public void setOssCover(String ossCover) {
+        this.ossCover = ossCover;
+    }
+
+    public Integer getGender() {
+        return gender;
+    }
+
+    public void setGender(Integer gender) {
+        this.gender = gender;
+    }
+
+    public String getCategoryText() {
+        return categoryText;
+    }
+
+    public void setCategoryText(String categoryText) {
+        this.categoryText = categoryText;
+    }
+
+    public Date getEstimatePublishTime() {
+        return estimatePublishTime;
+    }
+
+    public void setEstimatePublishTime(Date estimatePublishTime) {
+        this.estimatePublishTime = estimatePublishTime;
+    }
+
+    public Date getUpdatedAt() {
+        return updatedAt;
+    }
+
+    public void setUpdatedAt(Date updatedAt) {
+        this.updatedAt = updatedAt;
+    }
+
+    public Integer getSendStatus() {
+        return sendStatus;
+    }
+
+    public void setSendStatus(Integer sendStatus) {
+        this.sendStatus = sendStatus;
+    }
+
+    public Integer getRetryCount() {
+        return retryCount;
+    }
+
+    public void setRetryCount(Integer retryCount) {
+        this.retryCount = retryCount;
+    }
+}

+ 46 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/entity/ChangduSeriesSendResult.java

@@ -0,0 +1,46 @@
+package cn.com.ctop.job.bytedance.changdu.entity;
+
+public class ChangduSeriesSendResult {
+
+    private boolean success;
+    private String messageId;
+    private String errorMsg;
+
+    public boolean isSuccess() {
+        return success;
+    }
+
+    public void setSuccess(boolean success) {
+        this.success = success;
+    }
+
+    public String getMessageId() {
+        return messageId;
+    }
+
+    public void setMessageId(String messageId) {
+        this.messageId = messageId;
+    }
+
+    public String getErrorMsg() {
+        return errorMsg;
+    }
+
+    public void setErrorMsg(String errorMsg) {
+        this.errorMsg = errorMsg;
+    }
+
+    public static ChangduSeriesSendResult success(String messageId) {
+        ChangduSeriesSendResult result = new ChangduSeriesSendResult();
+        result.setSuccess(true);
+        result.setMessageId(messageId);
+        return result;
+    }
+
+    public static ChangduSeriesSendResult fail(String errorMsg) {
+        ChangduSeriesSendResult result = new ChangduSeriesSendResult();
+        result.setSuccess(false);
+        result.setErrorMsg(errorMsg);
+        return result;
+    }
+}

+ 39 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/mapper/ChangduSeriesFeishuMapper.java

@@ -0,0 +1,39 @@
+package cn.com.ctop.job.bytedance.changdu.mapper;
+
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesCardCandidate;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface ChangduSeriesFeishuMapper {
+
+    List<ChangduSeriesCardCandidate> listPendingSeries(@Param("limit") Integer limit,
+                                                       @Param("retryLimit") Integer retryLimit,
+                                                       @Param("processingTimeoutMinutes") Integer processingTimeoutMinutes);
+
+    int insertSendRecord(@Param("bookId") String bookId,
+                         @Param("receiveType") String receiveType,
+                         @Param("receiveId") String receiveId);
+
+    int claimFailedOrExpiredSendRecord(@Param("bookId") String bookId,
+                                       @Param("receiveType") String receiveType,
+                                       @Param("receiveId") String receiveId,
+                                       @Param("retryLimit") Integer retryLimit,
+                                       @Param("processingTimeoutMinutes") Integer processingTimeoutMinutes);
+
+    int markSendSuccess(@Param("bookId") String bookId,
+                        @Param("messageId") String messageId);
+
+    int markSendFailure(@Param("bookId") String bookId,
+                        @Param("errorMsg") String errorMsg);
+
+    int existsSeries(@Param("bookId") String bookId);
+
+    ChangduSeriesCardCandidate getSeriesByBookId(@Param("bookId") String bookId);
+
+    String getMessageIdByBookId(@Param("bookId") String bookId);
+
+    int upsertSeriesTypeBind(@Param("bookId") String bookId,
+                             @Param("seriesType") String seriesType,
+                             @Param("operator") String operator);
+}

+ 102 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/mapper/xml/ChangduSeriesFeishuMapper.xml

@@ -0,0 +1,102 @@
+<?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="cn.com.ctop.job.bytedance.changdu.mapper.ChangduSeriesFeishuMapper">
+
+    <select id="listPendingSeries" resultType="cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesCardCandidate">
+        select c.book_id as bookId,
+               c.series_name as seriesName,
+               c.oss_cover as ossCover,
+               c.gender as gender,
+               c.category_text as categoryText,
+               c.estimate_publish_time as estimatePublishTime,
+               c.updated_at as updatedAt,
+               r.send_status as sendStatus,
+               r.retry_count as retryCount
+        from deepthink.changdu_series_list c
+        left join deepthink.changdu_series_card_send_record r
+               on c.book_id = r.book_id
+        where r.book_id is null
+           or (r.send_status = 2 and r.retry_count &lt; #{retryLimit})
+           or (r.send_status = 0 and r.updated_at &lt; date_sub(now(), interval ${processingTimeoutMinutes} minute))
+        order by coalesce(r.updated_at, c.updated_at) asc
+        limit #{limit}
+    </select>
+
+    <insert id="insertSendRecord">
+        insert ignore into deepthink.changdu_series_card_send_record
+        (book_id, send_status, retry_count, receive_type, receive_id)
+        values
+        (#{bookId}, 0, 0, #{receiveType}, #{receiveId})
+    </insert>
+
+    <update id="claimFailedOrExpiredSendRecord">
+        update deepthink.changdu_series_card_send_record
+        set send_status = 0,
+            receive_type = #{receiveType},
+            receive_id = #{receiveId},
+            updated_at = current_timestamp
+        where book_id = #{bookId}
+          and (
+                (send_status = 2 and retry_count &lt; #{retryLimit})
+                or (send_status = 0 and updated_at &lt; date_sub(now(), interval ${processingTimeoutMinutes} minute))
+              )
+    </update>
+
+    <update id="markSendSuccess">
+        update deepthink.changdu_series_card_send_record
+        set send_status = 1,
+            message_id = #{messageId},
+            error_msg = null,
+            sent_at = now(),
+            updated_at = current_timestamp
+        where book_id = #{bookId}
+    </update>
+
+    <update id="markSendFailure">
+        update deepthink.changdu_series_card_send_record
+        set send_status = 2,
+            retry_count = retry_count + 1,
+            error_msg = #{errorMsg},
+            updated_at = current_timestamp
+        where book_id = #{bookId}
+    </update>
+
+    <select id="getSeriesByBookId" resultType="cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesCardCandidate">
+        select book_id as bookId,
+               series_name as seriesName,
+               oss_cover as ossCover,
+               gender as gender,
+               category_text as categoryText,
+               estimate_publish_time as estimatePublishTime,
+               updated_at as updatedAt
+        from deepthink.changdu_series_list
+        where book_id = #{bookId}
+        limit 1
+    </select>
+
+    <select id="existsSeries" resultType="java.lang.Integer">
+        select count(1)
+        from deepthink.changdu_series_list
+        where book_id = #{bookId}
+    </select>
+
+
+    <select id="getMessageIdByBookId" resultType="java.lang.String">
+        select message_id
+        from deepthink.changdu_series_card_send_record
+        where book_id = #{bookId}
+          and send_status = 1
+        limit 1
+    </select>
+
+    <insert id="upsertSeriesTypeBind">
+        insert into deepthink.changdu_series_type_bind
+        (book_id, series_type, created_by, updated_by)
+        values
+        (#{bookId}, #{seriesType}, #{operator}, #{operator})
+        on duplicate key update
+        series_type = values(series_type),
+        updated_by = values(updated_by),
+        updated_at = current_timestamp
+    </insert>
+</mapper>

+ 11 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/service/IChangduSeriesFeishuService.java

@@ -0,0 +1,11 @@
+package cn.com.ctop.job.bytedance.changdu.service;
+
+import com.lark.oapi.event.cardcallback.model.P2CardActionTrigger;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
+
+public interface IChangduSeriesFeishuService {
+
+    void pushPendingCards();
+
+    P2CardActionTriggerResponse handleCardAction(P2CardActionTrigger event);
+}

+ 405 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/service/impl/ChangduSeriesFeishuServiceImpl.java

@@ -0,0 +1,405 @@
+package cn.com.ctop.job.bytedance.changdu.service.impl;
+
+import cn.com.ctop.job.bytedance.changdu.config.ChangduSeriesFeishuProperties;
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesCardCandidate;
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesSendResult;
+import cn.com.ctop.job.bytedance.changdu.mapper.ChangduSeriesFeishuMapper;
+import cn.com.ctop.job.bytedance.changdu.service.IChangduSeriesFeishuService;
+import cn.com.ctop.job.bytedance.changdu.support.ChangduSeriesCardConstants;
+import cn.com.ctop.job.bytedance.changdu.support.ChangduSeriesCardBuilder;
+import cn.com.ctop.job.bytedance.changdu.support.ChangduSeriesFeishuClient;
+import com.alibaba.fastjson.JSON;
+import com.lark.oapi.event.cardcallback.model.CallBackAction;
+import com.lark.oapi.event.cardcallback.model.CallBackCard;
+import com.lark.oapi.event.cardcallback.model.CallBackOperator;
+import com.lark.oapi.event.cardcallback.model.CallBackToast;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTrigger;
+import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+@Service
+public class ChangduSeriesFeishuServiceImpl implements IChangduSeriesFeishuService {
+
+    private static final Logger log = LoggerFactory.getLogger(ChangduSeriesFeishuServiceImpl.class);
+    private static final ExecutorService CARD_UPDATE_EXECUTOR = Executors.newFixedThreadPool(2);
+
+    private final ChangduSeriesFeishuMapper changduSeriesFeishuMapper;
+    private final ChangduSeriesFeishuClient changduSeriesFeishuClient;
+    private final ChangduSeriesCardBuilder changduSeriesCardBuilder;
+    private final ChangduSeriesFeishuProperties properties;
+
+    public ChangduSeriesFeishuServiceImpl(ChangduSeriesFeishuMapper changduSeriesFeishuMapper,
+                                          ChangduSeriesFeishuClient changduSeriesFeishuClient,
+                                          ChangduSeriesCardBuilder changduSeriesCardBuilder,
+                                          ChangduSeriesFeishuProperties properties) {
+        this.changduSeriesFeishuMapper = changduSeriesFeishuMapper;
+        this.changduSeriesFeishuClient = changduSeriesFeishuClient;
+        this.changduSeriesCardBuilder = changduSeriesCardBuilder;
+        this.properties = properties;
+    }
+
+    @Override
+    public void pushPendingCards() {
+        if (!properties.hasAppCredential()) {
+            log.warn("changdu series feishu app credential not configured");
+            return;
+        }
+        if (!properties.hasReceiveTarget()) {
+            log.warn("changdu series feishu target not configured");
+            return;
+        }
+        List<ChangduSeriesCardCandidate> candidates = changduSeriesFeishuMapper.listPendingSeries(
+                properties.getBatchSize(),
+                properties.getRetryLimit(),
+                properties.getProcessingTimeoutMinutes()
+        );
+        if (candidates == null || candidates.isEmpty()) {
+            log.info("changdu series feishu no pending data");
+            return;
+        }
+        for (ChangduSeriesCardCandidate candidate : candidates) {
+            sendSingleCard(candidate);
+        }
+    }
+
+    @Override
+    public P2CardActionTriggerResponse handleCardAction(P2CardActionTrigger event) {
+        try {
+            CallBackAction action = event == null || event.getEvent() == null ? null : event.getEvent().getAction();
+            if (action == null) {
+                return buildToast("warning", "未识别到卡片动作");
+            }
+
+            String actionName = readActionValue(action.getValue(), "action");
+            log.info("changdu series card action received, actionName={}, actionValue={}, formValue={}",
+                    actionName,
+                    JSON.toJSONString(action.getValue()),
+                    JSON.toJSONString(action.getFormValue()));
+            if (StringUtils.equals("select_changdu_series_type", actionName)) {
+                return handleSelectSeriesType(action);
+            }
+            if (StringUtils.equals("reset_changdu_series_type", actionName)) {
+                return handleResetSeriesType(action);
+            }
+            if (StringUtils.equals("bind_changdu_series_type", actionName)) {
+                return handleBindSeriesType(event, action);
+            }
+            return buildToast("info", "已忽略非目标卡片操作");
+        } catch (Exception exception) {
+            log.error("changdu series handle card action failed, event={}", JSON.toJSONString(event), exception);
+            return buildToast("error", "绑定失败,请联系开发排查日志");
+        }
+    }
+
+    private P2CardActionTriggerResponse handleSelectSeriesType(CallBackAction action) {
+        String bookId = readActionValue(action.getValue(), "book_id");
+        String seriesType = readActionValue(action.getValue(), "series_type");
+        if (StringUtils.isBlank(bookId)) {
+            return buildToast("warning", "短剧ID为空,无法选择");
+        }
+        if (!getTypeOptions().contains(seriesType)) {
+            return buildToast("warning", "短剧类型不在允许范围内");
+        }
+        return rebuildCard(bookId, readActionValue(action.getValue(), "image_key"), seriesType);
+    }
+
+    private P2CardActionTriggerResponse handleResetSeriesType(CallBackAction action) {
+        String bookId = readActionValue(action.getValue(), "book_id");
+        if (StringUtils.isBlank(bookId)) {
+            return buildToast("warning", "短剧ID为空,无法重置");
+        }
+        return rebuildCard(bookId, readActionValue(action.getValue(), "image_key"), null);
+    }
+
+    private P2CardActionTriggerResponse handleBindSeriesType(P2CardActionTrigger event, CallBackAction action) {
+        String bookId = readActionValue(action.getValue(), "book_id");
+        String seriesType = resolveSeriesType(action);
+        String operator = resolveOperator(event == null ? null : event.getEvent().getOperator());
+
+        if (StringUtils.isBlank(bookId)) {
+            return buildToast("warning", "短剧ID为空,无法提交");
+        }
+        if (StringUtils.isBlank(seriesType)) {
+            return buildToast("warning", "请选择短剧类型后再绑定");
+        }
+        if (StringUtils.equals("MULTIPLE", seriesType)) {
+            return buildToast("warning", "短剧类型只能选择一个,请重置后重新选择");
+        }
+        if (!getTypeOptions().contains(seriesType)) {
+            return buildToast("warning", "短剧类型不在允许范围内");
+        }
+        if (changduSeriesFeishuMapper.existsSeries(bookId) <= 0) {
+            return buildToast("warning", "未找到对应短剧,绑定失败");
+        }
+
+        changduSeriesFeishuMapper.upsertSeriesTypeBind(bookId, seriesType, operator);
+        String seriesTypeLabel = ChangduSeriesCardConstants.getTypeLabel(seriesType);
+        log.info("changdu series type bind success, bookId={}, seriesType={}, seriesTypeLabel={}, operator={}", bookId, seriesType, seriesTypeLabel, operator);
+
+        String callbackOpenMessageId = event == null || event.getEvent() == null || event.getEvent().getContext() == null
+                ? null
+                : event.getEvent().getContext().getOpenMessageId();
+        String callbackToken = event == null || event.getEvent() == null ? null : event.getEvent().getToken();
+        asyncUpdateBoundCard(bookId, readActionValue(action.getValue(), "image_key"), seriesType, callbackToken, callbackOpenMessageId);
+        return new P2CardActionTriggerResponse();
+    }
+
+    private P2CardActionTriggerResponse rebuildCard(String bookId, String imageKey, String selectedType) {
+        return rebuildCard(bookId, imageKey, selectedType, false);
+    }
+
+    private P2CardActionTriggerResponse rebuildBoundCard(String bookId, String imageKey, String selectedType) {
+        return rebuildCard(bookId, imageKey, selectedType, true);
+    }
+
+    private boolean updateBoundMessageCard(String bookId, String imageKey, String selectedType, String callbackOpenMessageId) {
+        ChangduSeriesCardCandidate candidate = changduSeriesFeishuMapper.getSeriesByBookId(bookId);
+        if (candidate == null) {
+            log.warn("changdu series update bound message skipped, candidate not found, bookId={}", bookId);
+            return false;
+        }
+        String effectiveImageKey = imageKey;
+        if (StringUtils.isBlank(effectiveImageKey)) {
+            effectiveImageKey = changduSeriesFeishuClient.uploadImageAndGetKey(candidate.getOssCover());
+        }
+        String messageId = changduSeriesFeishuMapper.getMessageIdByBookId(bookId);
+        if (StringUtils.isBlank(messageId)) {
+            messageId = callbackOpenMessageId;
+            log.info("changdu series update bound message use callback openMessageId, bookId={}, openMessageId={}", bookId, callbackOpenMessageId);
+        }
+        if (StringUtils.isBlank(messageId)) {
+            log.warn("changdu series update bound message skipped, messageId not found, bookId={}, callbackOpenMessageId={}", bookId, callbackOpenMessageId);
+            return false;
+        }
+        String cardJson = changduSeriesCardBuilder.buildCard(candidate, effectiveImageKey, selectedType, true);
+        return changduSeriesFeishuClient.updateInteractiveCard(messageId, cardJson);
+    }
+
+
+    private void asyncUpdateBoundCard(String bookId, String imageKey, String selectedType, String callbackToken, String callbackOpenMessageId) {
+        CARD_UPDATE_EXECUTOR.submit(new Runnable() {
+            @Override
+            public void run() {
+                sleepSilently(1000L);
+                ChangduSeriesCardCandidate candidate = changduSeriesFeishuMapper.getSeriesByBookId(bookId);
+                if (candidate == null) {
+                    log.warn("changdu series async update skipped, candidate not found, bookId={}", bookId);
+                    return;
+                }
+                String effectiveImageKey = imageKey;
+                if (StringUtils.isBlank(effectiveImageKey)) {
+                    effectiveImageKey = changduSeriesFeishuClient.uploadImageAndGetKey(candidate.getOssCover());
+                }
+                String cardJson = changduSeriesCardBuilder.buildCard(candidate, effectiveImageKey, selectedType, true);
+                boolean updated = false;
+                if (StringUtils.isNotBlank(callbackToken)) {
+                    updated = changduSeriesFeishuClient.delayUpdateInteractiveCard(callbackToken, cardJson);
+                }
+                if (!updated) {
+                    updated = updateBoundMessageCard(bookId, effectiveImageKey, selectedType, callbackOpenMessageId);
+                }
+                log.info("changdu series async update finished, bookId={}, updated={}, callbackTokenPresent={}", bookId, updated, StringUtils.isNotBlank(callbackToken));
+            }
+        });
+    }
+
+    private void sleepSilently(long millis) {
+        try {
+            Thread.sleep(millis);
+        } catch (InterruptedException interruptedException) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    private P2CardActionTriggerResponse rebuildCard(String bookId, String imageKey, String selectedType, boolean bound) {
+        ChangduSeriesCardCandidate candidate = changduSeriesFeishuMapper.getSeriesByBookId(bookId);
+        if (candidate == null) {
+            return buildToast("warning", "未找到对应短剧,无法更新卡片");
+        }
+        String effectiveImageKey = imageKey;
+        if (StringUtils.isBlank(effectiveImageKey)) {
+            effectiveImageKey = changduSeriesFeishuClient.uploadImageAndGetKey(candidate.getOssCover());
+        }
+        String cardJson = changduSeriesCardBuilder.buildCard(candidate, effectiveImageKey, selectedType, bound);
+        return buildCardUpdate(cardJson);
+    }
+
+    private void sendSingleCard(ChangduSeriesCardCandidate candidate) {
+        if (candidate == null || StringUtils.isBlank(candidate.getBookId())) {
+            return;
+        }
+        if (!claimSendRecord(candidate.getBookId())) {
+            return;
+        }
+
+        try {
+            String imageKey = changduSeriesFeishuClient.uploadImageAndGetKey(candidate.getOssCover());
+            String cardJson = changduSeriesCardBuilder.buildCard(candidate, imageKey);
+            ChangduSeriesSendResult result = changduSeriesFeishuClient.sendInteractiveCard(
+                    properties.getReceiveType(),
+                    properties.getReceiveId(),
+                    cardJson
+            );
+            if (result.isSuccess()) {
+                changduSeriesFeishuMapper.markSendSuccess(candidate.getBookId(), result.getMessageId());
+                log.info("changdu series feishu card sent, bookId={}, messageId={}", candidate.getBookId(), result.getMessageId());
+                return;
+            }
+            changduSeriesFeishuMapper.markSendFailure(candidate.getBookId(), trimError(result.getErrorMsg()));
+            log.warn("changdu series feishu card send failed, bookId={}, error={}", candidate.getBookId(), result.getErrorMsg());
+        } catch (Exception exception) {
+            changduSeriesFeishuMapper.markSendFailure(candidate.getBookId(), trimError(exception.getMessage()));
+            log.error("changdu series feishu send exception, bookId={}", candidate.getBookId(), exception);
+        }
+    }
+
+    private boolean claimSendRecord(String bookId) {
+        int inserted = changduSeriesFeishuMapper.insertSendRecord(bookId, properties.getReceiveType(), properties.getReceiveId());
+        if (inserted > 0) {
+            return true;
+        }
+        int updated = changduSeriesFeishuMapper.claimFailedOrExpiredSendRecord(
+                bookId,
+                properties.getReceiveType(),
+                properties.getReceiveId(),
+                properties.getRetryLimit(),
+                properties.getProcessingTimeoutMinutes()
+        );
+        return updated > 0;
+    }
+
+    private String resolveSeriesType(CallBackAction action) {
+        String actionSeriesType = readActionValue(action.getValue(), "series_type");
+        if (StringUtils.isNotBlank(actionSeriesType)) {
+            return actionSeriesType;
+        }
+        String selectSeriesType = readFormValue(action.getFormValue(), "series_type");
+        if (StringUtils.isNotBlank(selectSeriesType)) {
+            return selectSeriesType;
+        }
+        return readCheckedSeriesType(action.getFormValue());
+    }
+
+    private String readCheckedSeriesType(Map<String, Object> formValues) {
+        if (formValues == null || formValues.isEmpty()) {
+            return null;
+        }
+        String selectedType = null;
+        for (String typeValue : ChangduSeriesCardConstants.TYPE_OPTIONS) {
+            Object rawValue = formValues.get("series_type_" + typeValue);
+            if (!isChecked(rawValue)) {
+                continue;
+            }
+            if (StringUtils.isNotBlank(selectedType)) {
+                return "MULTIPLE";
+            }
+            selectedType = typeValue;
+        }
+        return selectedType;
+    }
+
+    private boolean isChecked(Object rawValue) {
+        if (rawValue == null) {
+            return false;
+        }
+        if (rawValue instanceof Boolean) {
+            return ((Boolean) rawValue).booleanValue();
+        }
+        if (rawValue instanceof Number) {
+            return ((Number) rawValue).intValue() != 0;
+        }
+        if (rawValue instanceof Map) {
+            Map<?, ?> valueMap = (Map<?, ?>) rawValue;
+            Object checked = valueMap.get("checked");
+            if (checked == null) {
+                checked = valueMap.get("value");
+            }
+            if (checked == null) {
+                checked = valueMap.get("selected");
+            }
+            return isChecked(checked);
+        }
+        String value = String.valueOf(rawValue);
+        return StringUtils.equalsIgnoreCase(value, "true")
+                || StringUtils.equals(value, "1")
+                || StringUtils.equalsIgnoreCase(value, "checked")
+                || StringUtils.equalsIgnoreCase(value, "selected");
+    }
+
+    private String resolveOperator(CallBackOperator operator) {
+        if (operator == null) {
+            return "unknown";
+        }
+        if (StringUtils.isNotBlank(operator.getUserId())) {
+            return operator.getUserId();
+        }
+        if (StringUtils.isNotBlank(operator.getOpenId())) {
+            return operator.getOpenId();
+        }
+        if (StringUtils.isNotBlank(operator.getUnionId())) {
+            return operator.getUnionId();
+        }
+        return "unknown";
+    }
+
+    private String readActionValue(Map<String, Object> values, String key) {
+        if (values == null || values.isEmpty() || StringUtils.isBlank(key)) {
+            return null;
+        }
+        Object rawValue = values.get(key);
+        return rawValue == null ? null : String.valueOf(rawValue);
+    }
+
+    private String readFormValue(Map<String, Object> formValues, String key) {
+        if (formValues == null || formValues.isEmpty() || StringUtils.isBlank(key)) {
+            return null;
+        }
+        Object rawValue = formValues.get(key);
+        if (rawValue == null) {
+            return null;
+        }
+        if (rawValue instanceof Map) {
+            Object value = ((Map<?, ?>) rawValue).get("value");
+            return value == null ? null : String.valueOf(value);
+        }
+        return String.valueOf(rawValue);
+    }
+
+    private List<String> getTypeOptions() {
+        return ChangduSeriesCardConstants.TYPE_OPTIONS;
+    }
+
+    private String trimError(String errorMsg) {
+        String message = StringUtils.defaultIfBlank(errorMsg, "未知异常");
+        if (message.length() <= 1000) {
+            return message;
+        }
+        return message.substring(0, 1000);
+    }
+
+    private P2CardActionTriggerResponse buildCardUpdate(String cardJson) {
+        P2CardActionTriggerResponse response = new P2CardActionTriggerResponse();
+        CallBackCard card = new CallBackCard();
+        card.setType("raw");
+        card.setData(JSON.parseObject(cardJson));
+        response.setCard(card);
+        return response;
+    }
+
+    private P2CardActionTriggerResponse buildToast(String type, String content) {
+        P2CardActionTriggerResponse response = new P2CardActionTriggerResponse();
+        CallBackToast toast = new CallBackToast();
+        toast.setType(type);
+        toast.setContent(content);
+        response.setToast(toast);
+        return response;
+    }
+}

+ 289 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/support/ChangduSeriesCardBuilder.java

@@ -0,0 +1,289 @@
+package cn.com.ctop.job.bytedance.changdu.support;
+
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesCardCandidate;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Component;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+@Component
+public class ChangduSeriesCardBuilder {
+
+    private static final String DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+
+    public String buildCard(ChangduSeriesCardCandidate candidate) {
+        return buildCard(candidate, null);
+    }
+
+    public String buildCard(ChangduSeriesCardCandidate candidate, String imageKey) {
+        return buildCard(candidate, imageKey, null, false);
+    }
+
+    public String buildCard(ChangduSeriesCardCandidate candidate, String imageKey, String selectedType) {
+        return buildCard(candidate, imageKey, selectedType, false);
+    }
+
+    public String buildCard(ChangduSeriesCardCandidate candidate, String imageKey, String selectedType, boolean bound) {
+        JSONObject card = new JSONObject();
+        card.put("schema", "2.0");
+
+        JSONObject config = new JSONObject();
+        config.put("update_multi", true);
+        config.put("enable_forward", false);
+        config.put("width_mode", "fill");
+        card.put("config", config);
+
+        card.put("header", buildHeader(bound));
+
+        JSONObject body = new JSONObject();
+        JSONArray elements = new JSONArray();
+        if (bound) {
+            elements.add(buildBoundSection(candidate, selectedType));
+        } else {
+            elements.add(buildMainForm(candidate, imageKey, selectedType, false));
+        }
+        body.put("elements", elements);
+        card.put("body", body);
+        return card.toJSONString();
+    }
+
+    private JSONObject buildHeader(boolean bound) {
+        JSONObject header = new JSONObject();
+        header.put("template", "blue");
+        JSONObject title = new JSONObject();
+        title.put("tag", "plain_text");
+        title.put("content", bound
+                ? ChangduSeriesCardConstants.CARD_TITLE + "(已绑定)"
+                : ChangduSeriesCardConstants.CARD_TITLE);
+        header.put("title", title);
+        return header;
+    }
+
+    private JSONObject buildMainForm(ChangduSeriesCardCandidate candidate, String imageKey, String selectedType, boolean bound) {
+        return buildMainSection(candidate, imageKey, selectedType, bound);
+    }
+
+    private JSONObject buildMainSection(ChangduSeriesCardCandidate candidate, String imageKey, String selectedType, boolean bound) {
+        JSONObject columnSet = new JSONObject();
+        columnSet.put("tag", "column_set");
+        columnSet.put("horizontal_spacing", "12px");
+        columnSet.put("horizontal_align", "left");
+        columnSet.put("flex_mode", "none");
+
+        JSONArray columns = new JSONArray();
+        if (StringUtils.isNotBlank(imageKey)) {
+            columns.add(buildImageColumn(imageKey));
+        }
+        columns.add(buildInfoColumn(candidate, imageKey, selectedType, bound));
+        columnSet.put("columns", columns);
+        return columnSet;
+    }
+
+    private JSONObject buildImageColumn(String imageKey) {
+        JSONObject column = new JSONObject();
+        column.put("tag", "column");
+        column.put("width", "180px");
+        column.put("vertical_align", "top");
+
+        JSONArray elements = new JSONArray();
+        elements.add(buildImage(imageKey));
+        column.put("elements", elements);
+        return column;
+    }
+
+    private JSONObject buildInfoColumn(ChangduSeriesCardCandidate candidate, String imageKey, String selectedType, boolean bound) {
+        JSONObject column = new JSONObject();
+        column.put("tag", "column");
+        column.put("width", "weighted");
+        column.put("weight", 1);
+        column.put("vertical_align", "top");
+
+        JSONArray elements = new JSONArray();
+        elements.add(buildTitleDiv(candidate.getSeriesName()));
+        elements.add(buildInfoDiv("短剧ID:" + defaultText(candidate.getBookId())));
+        elements.add(buildInfoDiv("受众性别:" + formatGender(candidate.getGender())));
+        elements.add(buildInfoDiv("分类:" + defaultText(candidate.getCategoryText())));
+        elements.add(buildInfoDiv("预估投放时间:" + formatDateTime(candidate.getEstimatePublishTime())));
+        if (bound) {
+            elements.add(buildBoundTypeDiv(selectedType));
+        } else {
+            elements.add(buildTypeRow(candidate, imageKey));
+        }
+        column.put("elements", elements);
+        return column;
+    }
+
+    private JSONObject buildImage(String imageKey) {
+        JSONObject image = new JSONObject();
+        image.put("tag", "img");
+        image.put("img_key", imageKey);
+        image.put("scale_type", ChangduSeriesCardConstants.COVER_IMAGE_SCALE_TYPE);
+        image.put("preview", true);
+        image.put("corner_radius", "8px");
+        JSONObject alt = new JSONObject();
+        alt.put("tag", "plain_text");
+        alt.put("content", "短剧封面");
+        image.put("alt", alt);
+        return image;
+    }
+
+    private JSONObject buildTitleDiv(String content) {
+        JSONObject div = new JSONObject();
+        div.put("tag", "div");
+        JSONObject text = new JSONObject();
+        text.put("tag", "lark_md");
+        text.put("content", "**" + escapeMarkdown(defaultText(content)) + "**");
+        div.put("text", text);
+        return div;
+    }
+
+    private JSONObject buildInfoDiv(String content) {
+        JSONObject div = new JSONObject();
+        div.put("tag", "div");
+        JSONObject text = new JSONObject();
+        text.put("tag", "plain_text");
+        text.put("content", defaultText(content));
+        div.put("text", text);
+        return div;
+    }
+
+    private JSONObject buildBoundTypeDiv(String selectedType) {
+        return buildInfoDiv("短剧类型:" + ChangduSeriesCardConstants.getTypeLabel(selectedType));
+    }
+
+
+    private JSONObject buildBoundSection(ChangduSeriesCardCandidate candidate, String selectedType) {
+        JSONObject column = new JSONObject();
+        column.put("tag", "column_set");
+        column.put("horizontal_align", "left");
+        column.put("horizontal_spacing", "8px");
+
+        JSONArray columns = new JSONArray();
+        JSONObject infoColumn = new JSONObject();
+        infoColumn.put("tag", "column");
+        infoColumn.put("width", "weighted");
+        infoColumn.put("weight", 1);
+        infoColumn.put("vertical_align", "top");
+
+        JSONArray elements = new JSONArray();
+        elements.add(buildTitleDiv(candidate.getSeriesName()));
+        elements.add(buildInfoDiv("短剧ID:" + defaultText(candidate.getBookId())));
+        elements.add(buildInfoDiv("受众性别:" + formatGender(candidate.getGender())));
+        elements.add(buildInfoDiv("分类:" + defaultText(candidate.getCategoryText())));
+        elements.add(buildInfoDiv("预估投放时间:" + formatDateTime(candidate.getEstimatePublishTime())));
+        elements.add(buildBoundTypeDiv(selectedType));
+        infoColumn.put("elements", elements);
+        columns.add(infoColumn);
+        column.put("columns", columns);
+        return column;
+    }
+
+    private JSONObject buildTypeRow(ChangduSeriesCardCandidate candidate, String imageKey) {
+        JSONObject container = new JSONObject();
+        container.put("tag", "column_set");
+        container.put("horizontal_align", "left");
+        container.put("horizontal_spacing", "4px");
+        container.put("flex_mode", "none");
+
+        JSONArray columns = new JSONArray();
+        columns.add(buildInlineTextColumn("短剧类型:"));
+        for (String typeValue : ChangduSeriesCardConstants.TYPE_OPTIONS) {
+            columns.add(buildInlineButtonColumn(buildTypeButton(candidate, imageKey, typeValue)));
+        }
+        container.put("columns", columns);
+        return container;
+    }
+
+    private JSONObject buildInlineTextColumn(String content) {
+        JSONObject column = new JSONObject();
+        column.put("tag", "column");
+        column.put("width", "auto");
+        column.put("vertical_align", "center");
+        JSONArray elements = new JSONArray();
+        elements.add(buildInfoDiv(content));
+        column.put("elements", elements);
+        return column;
+    }
+
+    private JSONObject buildInlineButtonColumn(JSONObject button) {
+        JSONObject column = new JSONObject();
+        column.put("tag", "column");
+        column.put("width", "auto");
+        column.put("vertical_align", "center");
+        JSONArray elements = new JSONArray();
+        elements.add(button);
+        column.put("elements", elements);
+        return column;
+    }
+
+    private JSONObject buildTypeButton(ChangduSeriesCardCandidate candidate, String imageKey, String seriesType) {
+        JSONObject button = new JSONObject();
+        button.put("tag", "button");
+        button.put("type", "default");
+        button.put("width", "default");
+        button.put("name", "bind_series_type_" + seriesType);
+
+        JSONObject text = new JSONObject();
+        text.put("tag", "plain_text");
+        text.put("content", ChangduSeriesCardConstants.getTypeLabel(seriesType));
+        button.put("text", text);
+
+        JSONArray behaviors = new JSONArray();
+        JSONObject behavior = new JSONObject();
+        behavior.put("type", "callback");
+        JSONObject value = new JSONObject();
+        value.put("action", "bind_changdu_series_type");
+        value.put("book_id", candidate.getBookId());
+        value.put("series_type", seriesType);
+        if (StringUtils.isNotBlank(imageKey)) {
+            value.put("image_key", imageKey);
+        }
+        behavior.put("value", value);
+        behaviors.add(behavior);
+        button.put("behaviors", behaviors);
+        return button;
+    }
+
+    private String formatGender(Integer gender) {
+        if (gender == null) {
+            return "-";
+        }
+        if (gender.intValue() == 0) {
+            return "女频";
+        }
+        if (gender.intValue() == 1) {
+            return "男频";
+        }
+        if (gender.intValue() == 2) {
+            return "通用";
+        }
+        return String.valueOf(gender);
+    }
+
+    private String formatDateTime(Date date) {
+        if (date == null) {
+            return "-";
+        }
+        return new SimpleDateFormat(DATETIME_PATTERN).format(date);
+    }
+
+    private String defaultText(String text) {
+        return StringUtils.defaultIfBlank(text, "-");
+    }
+
+    private String escapeMarkdown(String text) {
+        return StringUtils.defaultString(text)
+                .replace("\\", "\\\\")
+                .replace("*", "\\*")
+                .replace("_", "\\_")
+                .replace("~", "\\~")
+                .replace("`", "\\`")
+                .replace("[", "\\[")
+                .replace("]", "\\]")
+                .replace("(", "\\(")
+                .replace(")", "\\)");
+    }
+}

+ 38 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/support/ChangduSeriesCardConstants.java

@@ -0,0 +1,38 @@
+package cn.com.ctop.job.bytedance.changdu.support;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class ChangduSeriesCardConstants {
+
+    public static final String CARD_TITLE = "常读短剧类型绑定";
+    public static final String COVER_IMAGE_SCALE_TYPE = "fit_horizontal";
+    public static final String TYPE_AI_REAL_COMIC = "1";
+    public static final String TYPE_COMMENTARY_COMIC = "2";
+    public static final String TYPE_OTHER = "3";
+    public static final List<String> TYPE_OPTIONS = Collections.unmodifiableList(Arrays.asList(
+            TYPE_AI_REAL_COMIC,
+            TYPE_COMMENTARY_COMIC,
+            TYPE_OTHER
+    ));
+    public static final Map<String, String> TYPE_OPTION_LABELS = buildTypeOptionLabels();
+
+    private ChangduSeriesCardConstants() {
+    }
+
+    public static String getTypeLabel(String value) {
+        String label = TYPE_OPTION_LABELS.get(value);
+        return label == null ? value : label;
+    }
+
+    private static Map<String, String> buildTypeOptionLabels() {
+        Map<String, String> labels = new LinkedHashMap<>();
+        labels.put(TYPE_AI_REAL_COMIC, "AI真人漫");
+        labels.put(TYPE_COMMENTARY_COMIC, "解说漫");
+        labels.put(TYPE_OTHER, "其他");
+        return Collections.unmodifiableMap(labels);
+    }
+}

+ 281 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/changdu/support/ChangduSeriesFeishuClient.java

@@ -0,0 +1,281 @@
+package cn.com.ctop.job.bytedance.changdu.support;
+
+import cn.com.ctop.job.bytedance.changdu.config.ChangduSeriesFeishuProperties;
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesSendResult;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPatch;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+@Component
+public class ChangduSeriesFeishuClient {
+
+    private static final Logger log = LoggerFactory.getLogger(ChangduSeriesFeishuClient.class);
+
+    private static final String TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
+    private static final String MESSAGE_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=%s";
+    private static final String IMAGE_URL = "https://open.feishu.cn/open-apis/im/v1/images";
+    private static final String UPDATE_MESSAGE_URL = "https://open.feishu.cn/open-apis/im/v1/messages/%s";
+    private static final String DELAY_UPDATE_CARD_URL = "https://open.feishu.cn/open-apis/interactive/v1/card/update";
+    private static final String CHANGDU_COVER_DOMAIN = "https://img.starapp.cn/";
+    private static final int UPDATE_CARD_MAX_RETRY = 3;
+    private static final long UPDATE_CARD_RETRY_INTERVAL_MS = 600L;
+
+    private final ChangduSeriesFeishuProperties properties;
+
+
+    private volatile String cachedToken;
+    private volatile long tokenExpireAt;
+
+    public ChangduSeriesFeishuClient(ChangduSeriesFeishuProperties properties) {
+        this.properties = properties;
+    }
+
+    public ChangduSeriesSendResult sendInteractiveCard(String receiveType, String receiveId, String cardJson) {
+        if (StringUtils.isBlank(receiveType) || StringUtils.isBlank(receiveId)) {
+            return ChangduSeriesSendResult.fail("飞书接收目标未配置");
+        }
+        try {
+            String token = getTenantToken();
+            JSONObject request = new JSONObject();
+            request.put("receive_id", receiveId);
+            request.put("msg_type", "interactive");
+            request.put("content", cardJson);
+            JSONObject response = postJson(String.format(MESSAGE_URL, receiveType), request, token);
+            if (response.getInteger("code") != null && response.getInteger("code") == 0) {
+                JSONObject data = response.getJSONObject("data");
+                String messageId = data == null ? null : data.getString("message_id");
+                return ChangduSeriesSendResult.success(messageId);
+            }
+            return ChangduSeriesSendResult.fail(extractError(response));
+        } catch (Exception exception) {
+            log.error("send changdu feishu card error", exception);
+            return ChangduSeriesSendResult.fail(exception.getMessage());
+        }
+    }
+
+    public String uploadImageAndGetKey(String ossCover) {
+        if (StringUtils.isBlank(ossCover)) {
+            return null;
+        }
+        try {
+            String token = getTenantToken();
+            byte[] imageBytes = downloadImage(resolveCoverUrl(ossCover));
+            if (imageBytes == null || imageBytes.length == 0) {
+                return null;
+            }
+            return uploadImage(token, imageBytes, fileNameOf(ossCover));
+        } catch (Exception exception) {
+            log.warn("upload changdu cover to feishu failed, ossCover={}", ossCover, exception);
+            return null;
+        }
+    }
+
+    public boolean updateInteractiveCard(String messageId, String cardJson) {
+        if (StringUtils.isBlank(messageId) || StringUtils.isBlank(cardJson)) {
+            return false;
+        }
+        for (int attempt = 1; attempt <= UPDATE_CARD_MAX_RETRY; attempt++) {
+            try {
+                String token = getTenantToken();
+                JSONObject request = new JSONObject();
+                request.put("content", cardJson);
+                JSONObject response = patchJson(String.format(UPDATE_MESSAGE_URL, messageId), request, token);
+                Integer code = response.getInteger("code");
+                if (code != null && code == 0) {
+                    if (attempt > 1) {
+                        log.info("update changdu feishu card success after retry, messageId={}, attempt={}", messageId, attempt);
+                    }
+                    return true;
+                }
+                log.warn("update changdu feishu card failed, messageId={}, attempt={}, response={}", messageId, attempt, response);
+            } catch (Exception exception) {
+                log.warn("update changdu feishu card exception, messageId={}, attempt={}", messageId, attempt, exception);
+            }
+            if (attempt < UPDATE_CARD_MAX_RETRY) {
+                sleepSilently(UPDATE_CARD_RETRY_INTERVAL_MS * attempt);
+            }
+        }
+        return false;
+    }
+
+
+    public boolean delayUpdateInteractiveCard(String callbackToken, String cardJson) {
+        if (StringUtils.isBlank(callbackToken) || StringUtils.isBlank(cardJson)) {
+            return false;
+        }
+        try {
+            String token = getTenantToken();
+            JSONObject request = new JSONObject();
+            request.put("token", callbackToken);
+            request.put("card", JSON.parseObject(cardJson));
+            JSONObject response = postJson(DELAY_UPDATE_CARD_URL, request, token);
+            Integer code = response.getInteger("code");
+            if (code != null && code == 0) {
+                return true;
+            }
+            log.warn("delay update changdu feishu card failed, callbackToken={}, response={}", callbackToken, response);
+            return false;
+        } catch (Exception exception) {
+            log.warn("delay update changdu feishu card exception, callbackToken={}", callbackToken, exception);
+            return false;
+        }
+    }
+
+    public synchronized String getTenantToken() throws IOException {
+        if (!properties.hasAppCredential()) {
+            throw new IOException("飞书应用 app-id/app-secret 未配置");
+        }
+        long now = System.currentTimeMillis();
+        if (StringUtils.isNotBlank(cachedToken) && now < tokenExpireAt) {
+            return cachedToken;
+        }
+        JSONObject request = new JSONObject();
+        request.put("app_id", properties.getEffectiveAppId());
+        request.put("app_secret", properties.getEffectiveAppSecret());
+        JSONObject response = postJson(TOKEN_URL, request, null);
+        Integer code = response.getInteger("code");
+        if (code != null && code == 0) {
+            cachedToken = response.getString("tenant_access_token");
+            Integer expire = response.getInteger("expire");
+            int expireSeconds = expire == null ? 3600 : expire.intValue();
+            tokenExpireAt = System.currentTimeMillis() + Math.max(expireSeconds - 60, 60) * 1000L;
+            return cachedToken;
+        }
+        throw new IOException(extractError(response));
+    }
+
+    private JSONObject postJson(String url, JSONObject request, String token) throws IOException {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpPost httpPost = new HttpPost(url);
+            httpPost.setHeader("Content-Type", "application/json; charset=utf-8");
+            if (StringUtils.isNotBlank(token)) {
+                httpPost.setHeader("Authorization", "Bearer " + token);
+            }
+            HttpEntity entity = new StringEntity(request.toJSONString(), "UTF-8");
+            httpPost.setEntity(entity);
+            CloseableHttpResponse response = client.execute(httpPost);
+            String body = EntityUtils.toString(response.getEntity(), "UTF-8");
+            return JSON.parseObject(body);
+        }
+    }
+
+
+    private JSONObject patchJson(String url, JSONObject request, String token) throws IOException {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpPatch httpPatch = new HttpPatch(url);
+            httpPatch.setHeader("Content-Type", "application/json; charset=utf-8");
+            if (StringUtils.isNotBlank(token)) {
+                httpPatch.setHeader("Authorization", "Bearer " + token);
+            }
+            HttpEntity entity = new StringEntity(request.toJSONString(), "UTF-8");
+            httpPatch.setEntity(entity);
+            CloseableHttpResponse response = client.execute(httpPatch);
+            String body = EntityUtils.toString(response.getEntity(), "UTF-8");
+            return JSON.parseObject(body);
+        }
+    }
+
+
+    private void sleepSilently(long millis) {
+        try {
+            Thread.sleep(millis);
+        } catch (InterruptedException interruptedException) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    private String uploadImage(String token, byte[] imageBytes, String fileName) throws IOException {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpPost httpPost = new HttpPost(IMAGE_URL);
+            httpPost.setHeader("Authorization", "Bearer " + token);
+            MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+            builder.addTextBody("image_type", "message");
+            builder.addBinaryBody("image", imageBytes, ContentType.APPLICATION_OCTET_STREAM, fileName);
+            httpPost.setEntity(builder.build());
+            CloseableHttpResponse response = client.execute(httpPost);
+            String body = EntityUtils.toString(response.getEntity(), "UTF-8");
+            JSONObject json = JSON.parseObject(body);
+            if (json.getInteger("code") != null && json.getInteger("code") == 0) {
+                JSONObject data = json.getJSONObject("data");
+                return data == null ? null : data.getString("image_key");
+            }
+            throw new IOException(extractError(json));
+        }
+    }
+
+    private byte[] downloadImage(String imageUrl) throws IOException {
+        HttpURLConnection connection = null;
+        InputStream inputStream = null;
+        ByteArrayOutputStream outputStream = null;
+        try {
+            connection = (HttpURLConnection) new URL(imageUrl).openConnection();
+            connection.setRequestMethod("GET");
+            connection.setConnectTimeout(5000);
+            connection.setReadTimeout(30000);
+            connection.connect();
+            inputStream = connection.getInputStream();
+            outputStream = new ByteArrayOutputStream();
+            byte[] buffer = new byte[4096];
+            int len;
+            while ((len = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, len);
+            }
+            return outputStream.toByteArray();
+        } finally {
+            if (outputStream != null) {
+                outputStream.close();
+            }
+            if (inputStream != null) {
+                inputStream.close();
+            }
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    private String resolveCoverUrl(String ossCover) {
+        if (StringUtils.startsWithIgnoreCase(ossCover, "http://") || StringUtils.startsWithIgnoreCase(ossCover, "https://")) {
+            return ossCover;
+        }
+        return StringUtils.appendIfMissing(CHANGDU_COVER_DOMAIN, "/") + StringUtils.removeStart(ossCover, "/");
+    }
+
+    private String fileNameOf(String ossCover) {
+        if (StringUtils.isBlank(ossCover)) {
+            return "cover.jpg";
+        }
+        int idx = ossCover.lastIndexOf('/');
+        return idx >= 0 ? ossCover.substring(idx + 1) : ossCover;
+    }
+
+    private String extractError(JSONObject response) {
+        if (response == null) {
+            return "飞书返回为空";
+        }
+        String message = response.getString("msg");
+        if (StringUtils.isBlank(message)) {
+            message = response.toJSONString();
+        }
+        return message;
+    }
+}

+ 166 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/controller/ChangduSeriesFeishuDebugController.java

@@ -0,0 +1,166 @@
+package cn.com.ctop.job.bytedance.controller;
+
+import cn.com.ctop.job.bytedance.changdu.config.ChangduSeriesFeishuProperties;
+import cn.com.ctop.job.bytedance.changdu.config.ChangduSeriesFeishuLegacyWsBridge;
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesCardCandidate;
+import cn.com.ctop.job.bytedance.changdu.entity.ChangduSeriesSendResult;
+import cn.com.ctop.job.bytedance.changdu.mapper.ChangduSeriesFeishuMapper;
+import cn.com.ctop.job.bytedance.changdu.service.IChangduSeriesFeishuService;
+import cn.com.ctop.job.bytedance.changdu.support.ChangduSeriesCardBuilder;
+import cn.com.ctop.job.bytedance.changdu.support.ChangduSeriesCardConstants;
+import cn.com.ctop.job.bytedance.changdu.support.ChangduSeriesFeishuClient;
+import com.alibaba.fastjson.JSONObject;
+import io.swagger.annotations.Api;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/debug/changduSeriesFeishu")
+@Api(tags = "常读飞书验证")
+public class ChangduSeriesFeishuDebugController {
+
+    private final IChangduSeriesFeishuService changduSeriesFeishuService;
+    private final ChangduSeriesFeishuMapper changduSeriesFeishuMapper;
+    private final ChangduSeriesCardBuilder changduSeriesCardBuilder;
+    private final ChangduSeriesFeishuClient changduSeriesFeishuClient;
+    private final ChangduSeriesFeishuProperties properties;
+    private final ChangduSeriesFeishuLegacyWsBridge changduSeriesFeishuLegacyWsBridge;
+
+    public ChangduSeriesFeishuDebugController(IChangduSeriesFeishuService changduSeriesFeishuService,
+                                              ChangduSeriesFeishuMapper changduSeriesFeishuMapper,
+                                              ChangduSeriesCardBuilder changduSeriesCardBuilder,
+                                              ChangduSeriesFeishuClient changduSeriesFeishuClient,
+                                              ChangduSeriesFeishuProperties properties,
+                                              ChangduSeriesFeishuLegacyWsBridge changduSeriesFeishuLegacyWsBridge) {
+        this.changduSeriesFeishuService = changduSeriesFeishuService;
+        this.changduSeriesFeishuMapper = changduSeriesFeishuMapper;
+        this.changduSeriesCardBuilder = changduSeriesCardBuilder;
+        this.changduSeriesFeishuClient = changduSeriesFeishuClient;
+        this.properties = properties;
+        this.changduSeriesFeishuLegacyWsBridge = changduSeriesFeishuLegacyWsBridge;
+    }
+
+    @GetMapping("/wsStatus")
+    public JSONObject wsStatus() {
+        JSONObject result = ok();
+        result.put("data", changduSeriesFeishuLegacyWsBridge.getHandlerStatus());
+        return result;
+    }
+
+    @GetMapping("/config")
+    public JSONObject config() {
+        JSONObject result = ok();
+        result.put("appId", properties.getAppId());
+        result.put("receiveType", properties.getReceiveType());
+        result.put("receiveId", properties.getReceiveId());
+        result.put("batchSize", properties.getBatchSize());
+        result.put("retryLimit", properties.getRetryLimit());
+        result.put("processingTimeoutMinutes", properties.getProcessingTimeoutMinutes());
+        result.put("hasAppCredential", properties.hasAppCredential());
+        result.put("hasReceiveTarget", properties.hasReceiveTarget());
+        result.put("cardTitle", ChangduSeriesCardConstants.CARD_TITLE);
+        result.put("typeOptions", ChangduSeriesCardConstants.TYPE_OPTIONS);
+        result.put("typeOptionLabels", ChangduSeriesCardConstants.TYPE_OPTION_LABELS);
+        return result;
+    }
+
+    @GetMapping("/pending")
+    public JSONObject pending(@RequestParam(defaultValue = "10") Integer limit) {
+        JSONObject result = ok();
+        List<ChangduSeriesCardCandidate> list = changduSeriesFeishuMapper.listPendingSeries(
+                limit,
+                properties.getRetryLimit(),
+                properties.getProcessingTimeoutMinutes()
+        );
+        result.put("count", list == null ? 0 : list.size());
+        result.put("data", list);
+        return result;
+    }
+
+    @GetMapping("/preview")
+    public JSONObject preview(@RequestParam String bookId) {
+        ChangduSeriesCardCandidate candidate = changduSeriesFeishuMapper.getSeriesByBookId(bookId);
+        if (candidate == null) {
+            return fail("未找到对应 bookId 的短剧数据");
+        }
+        JSONObject result = ok();
+        String imageKey = changduSeriesFeishuClient.uploadImageAndGetKey(candidate.getOssCover());
+        result.put("candidate", candidate);
+        result.put("imageKey", imageKey);
+        result.put("cardJson", changduSeriesCardBuilder.buildCard(candidate, imageKey));
+        return result;
+    }
+
+    @PostMapping("/sendOne")
+    public JSONObject sendOne(@RequestParam String bookId) {
+        ChangduSeriesCardCandidate candidate = changduSeriesFeishuMapper.getSeriesByBookId(bookId);
+        if (candidate == null) {
+            return fail("未找到对应 bookId 的短剧数据");
+        }
+        String imageKey = changduSeriesFeishuClient.uploadImageAndGetKey(candidate.getOssCover());
+        String cardJson = changduSeriesCardBuilder.buildCard(candidate, imageKey);
+        ChangduSeriesSendResult sendResult = changduSeriesFeishuClient.sendInteractiveCard(
+                properties.getReceiveType(),
+                properties.getReceiveId(),
+                cardJson
+        );
+        if (sendResult.isSuccess()) {
+            changduSeriesFeishuMapper.insertSendRecord(bookId, properties.getReceiveType(), properties.getReceiveId());
+            changduSeriesFeishuMapper.markSendSuccess(bookId, sendResult.getMessageId());
+        }
+        JSONObject result = sendResult.isSuccess() ? ok() : fail(sendResult.getErrorMsg());
+        result.put("bookId", bookId);
+        result.put("messageId", sendResult.getMessageId());
+        result.put("imageKey", imageKey);
+        result.put("cardJson", cardJson);
+        return result;
+    }
+
+    @PostMapping("/push")
+    public JSONObject push() {
+        changduSeriesFeishuService.pushPendingCards();
+        return ok();
+    }
+
+    @PostMapping("/bind")
+    public JSONObject bind(@RequestParam String bookId,
+                           @RequestParam String seriesType,
+                           @RequestParam(required = false) String operator) {
+        if (StringUtils.isBlank(bookId)) {
+            return fail("bookId 不能为空");
+        }
+        if (!ChangduSeriesCardConstants.TYPE_OPTIONS.contains(seriesType)) {
+            return fail("seriesType 不在可选范围内");
+        }
+        if (changduSeriesFeishuMapper.existsSeries(bookId) <= 0) {
+            return fail("未找到对应短剧");
+        }
+        String bindOperator = StringUtils.defaultIfBlank(operator, "manual-test");
+        changduSeriesFeishuMapper.upsertSeriesTypeBind(bookId, seriesType, bindOperator);
+        JSONObject result = ok();
+        result.put("bookId", bookId);
+        result.put("seriesType", seriesType);
+        result.put("seriesTypeLabel", ChangduSeriesCardConstants.getTypeLabel(seriesType));
+        result.put("operator", bindOperator);
+        return result;
+    }
+
+    private JSONObject ok() {
+        JSONObject result = new JSONObject();
+        result.put("success", true);
+        return result;
+    }
+
+    private JSONObject fail(String message) {
+        JSONObject result = new JSONObject();
+        result.put("success", false);
+        result.put("message", message);
+        return result;
+    }
+}

+ 26 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/handler/ChangduSeriesFeishuJob.java

@@ -0,0 +1,26 @@
+package cn.com.ctop.job.bytedance.handler;
+
+import cn.com.ctop.job.bytedance.changdu.service.IChangduSeriesFeishuService;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ChangduSeriesFeishuJob {
+
+    private static final Logger log = LoggerFactory.getLogger(ChangduSeriesFeishuJob.class);
+
+    private final IChangduSeriesFeishuService changduSeriesFeishuService;
+
+    public ChangduSeriesFeishuJob(IChangduSeriesFeishuService changduSeriesFeishuService) {
+        this.changduSeriesFeishuService = changduSeriesFeishuService;
+    }
+
+    @XxlJob("pushChangduSeriesFeishuCard")
+    public void pushChangduSeriesFeishuCard() {
+        log.info("pushChangduSeriesFeishuCard start");
+        changduSeriesFeishuService.pushPendingCards();
+        log.info("pushChangduSeriesFeishuCard finish");
+    }
+}

+ 29 - 0
job-bytedance/src/main/resources/changdu-series-feishu.sql

@@ -0,0 +1,29 @@
+create table if not exists deepthink.changdu_series_type_bind
+(
+    id          bigint auto_increment comment '自增主键' primary key,
+    book_id     varchar(64) not null comment '短剧ID',
+    series_type varchar(128) not null comment '短剧类型',
+    created_by  varchar(128) not null comment '创建人',
+    updated_by  varchar(128) not null comment '修改人',
+    created_at  timestamp default current_timestamp not null comment '创建时间',
+    updated_at  timestamp default current_timestamp not null on update current_timestamp comment '修改时间',
+    unique key uk_book_id (book_id),
+    key idx_series_type (series_type)
+) comment '常读短剧类型绑定表';
+
+create table if not exists deepthink.changdu_series_card_send_record
+(
+    id           bigint auto_increment comment '自增主键' primary key,
+    book_id      varchar(64) not null comment '短剧ID',
+    send_status  tinyint default 0 not null comment '发送状态:0=处理中,1=成功,2=失败',
+    retry_count  int default 0 not null comment '重试次数',
+    receive_type varchar(32) null comment '接收类型:chat_id/user_id',
+    receive_id   varchar(128) null comment '接收ID',
+    message_id   varchar(128) null comment '飞书消息ID',
+    error_msg    varchar(1024) null comment '失败原因',
+    sent_at      datetime null comment '发送成功时间',
+    created_at   timestamp default current_timestamp not null comment '创建时间',
+    updated_at   timestamp default current_timestamp not null on update current_timestamp comment '修改时间',
+    unique key uk_book_id (book_id),
+    key idx_status_retry (send_status, retry_count, updated_at)
+) comment '常读短剧飞书卡片发送记录表';

+ 7 - 0
job-bytedance/src/main/resources/changdu-series.properties

@@ -0,0 +1,7 @@
+changdu.series.feishu.app-id=cli_a75d0ab0f538100e
+changdu.series.feishu.app-secret=w7ZNwahiXkmh1xCVSjt8ccJsaleIL3Fc
+changdu.series.feishu.receive-type=chat_id
+changdu.series.feishu.receive-id=oc_1486da48c3a515b6c3439eab408295b3
+changdu.series.feishu.batch-size=100
+changdu.series.feishu.retry-limit=3
+changdu.series.feishu.processing-timeout-minutes=30