Browse Source

Merge remote-tracking branch 'origin/master'

zyy 2 tuần trước cách đây
mục cha
commit
971647e082
36 tập tin đã thay đổi với 1930 bổ sung12 xóa
  1. 19 1
      fs-admin/src/main/java/com/fs/his/controller/FsUserController.java
  2. 144 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgAuditIngestService.java
  3. 66 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgAuditPullService.java
  4. 56 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgAuditWxCpFactory.java
  5. 112 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgMediaFileService.java
  6. 32 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/ImageMsgHandler.java
  7. 44 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/QwMsgAuditMsgHandler.java
  8. 32 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/TextMsgHandler.java
  9. 33 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/VideoMsgHandler.java
  10. 33 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/VoiceMsgHandler.java
  11. 118 0
      fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/job/QwMsgAuditScheduleJob.java
  12. 31 4
      fs-qw-api-msg/src/main/java/com/tencent/wework/Finance.java
  13. 56 0
      fs-service/src/main/java/com/fs/aiSoundReplication/config/AsrConfig.java
  14. 15 0
      fs-service/src/main/java/com/fs/aiSoundReplication/service/AsrService.java
  15. 192 0
      fs-service/src/main/java/com/fs/aiSoundReplication/service/impl/AsrServiceImpl.java
  16. 34 0
      fs-service/src/main/java/com/fs/enums/MediaMsgTypeEnum.java
  17. 13 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  18. 3 0
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  19. 37 4
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  20. 9 0
      fs-service/src/main/java/com/fs/his/vo/FsUserVO.java
  21. 15 0
      fs-service/src/main/java/com/fs/his/vo/FsUserWatchDaysStatVO.java
  22. 48 0
      fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditMessage.java
  23. 31 0
      fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditRaw.java
  24. 21 0
      fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditSeq.java
  25. 75 0
      fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditMessageMapper.java
  26. 69 0
      fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditRawMapper.java
  27. 75 0
      fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditSeqMapper.java
  28. 82 0
      fs-service/src/main/java/com/fs/utils/AmrToMp3Util.java
  29. 15 0
      fs-service/src/main/resources/application-config-dev.yml
  30. 25 0
      fs-service/src/main/resources/application-config-druid-ddgy.yml
  31. 1 1
      fs-service/src/main/resources/application-druid-hsyy.yml
  32. 2 2
      fs-service/src/main/resources/application-druid-mengniu.yml
  33. 53 0
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  34. 148 0
      fs-service/src/main/resources/mapper/qw/QwMsgAuditMessageMapper.xml
  35. 113 0
      fs-service/src/main/resources/mapper/qw/QwMsgAuditRawMapper.xml
  36. 78 0
      fs-service/src/main/resources/mapper/qw/QwMsgAuditSeqMapper.xml

+ 19 - 1
fs-admin/src/main/java/com/fs/his/controller/FsUserController.java

@@ -5,7 +5,9 @@ import java.util.stream.Collectors;
 
 import com.alibaba.fastjson.JSON;
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fs.common.constant.HttpStatus;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.core.domain.entity.SysRole;
 import com.fs.common.core.domain.entity.SysUser;
@@ -63,7 +65,6 @@ import com.fs.common.enums.BusinessType;
 import com.fs.his.domain.FsUser;
 import com.fs.his.service.IFsUserService;
 import com.fs.common.utils.poi.ExcelUtil;
-import com.fs.common.core.page.TableDataInfo;
 
 import static com.fs.his.utils.PhoneUtil.*;
 
@@ -423,4 +424,21 @@ public class FsUserController extends BaseController
         }
     }
 
+    @PreAuthorize("@ss.hasPermi('his:user:statistics')")
+    @GetMapping("/statisticsList")
+    @ApiOperation("按照销售公司查询会员数据(按会员去重,含近3/5/7自然日看课天数;关系多行取create_time最新)")
+    public TableDataInfo statisticsList(FsUser param) {
+        if (param.getCompanyId() == null) {
+            TableDataInfo dataInfo = new TableDataInfo();
+            dataInfo.setCode(HttpStatus.SUCCESS);
+            dataInfo.setMsg("需要传入公司id");
+            dataInfo.setRows(Collections.emptyList());
+            dataInfo.setTotal(0);
+            return dataInfo;
+        }
+        startPage();
+        List<FsUserVO> list = fsUserService.selectStatisticsList(param);
+        return getDataTable(list);
+    }
+
 }

+ 144 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgAuditIngestService.java

@@ -0,0 +1,144 @@
+package com.fs.app.msgarchives;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.app.msgarchives.handler.QwMsgAuditMsgHandler;
+import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import com.fs.qw.domain.audit.QwMsgAuditRaw;
+import com.fs.qw.domain.audit.QwMsgAuditSeq;
+import com.fs.qw.mapper.QwCompanyMapper;
+import com.fs.qw.mapper.QwMsgAuditMessageMapper;
+import com.fs.qw.mapper.QwMsgAuditRawMapper;
+import com.fs.qw.mapper.QwMsgAuditSeqMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+
+/**
+ * 会话存档,步骤:拉取 -> 解密 -> 存入库表
+ *
+ * @author caoliqin
+ */
+@Service
+@Slf4j
+public class QwMsgAuditIngestService {
+
+    private final QwCompanyMapper qwCompanyMapper;
+    private final QwMsgAuditSeqMapper seqMapper;
+    private final QwMsgAuditRawMapper rawMapper;
+    private final QwMsgAuditMessageMapper messageMapper;
+    private final QwMsgAuditPullService pullService;
+    private final List<QwMsgAuditMsgHandler> handlers;
+
+    public QwMsgAuditIngestService(QwCompanyMapper qwCompanyMapper,
+                                   QwMsgAuditSeqMapper seqMapper,
+                                   QwMsgAuditRawMapper rawMapper,
+                                   QwMsgAuditMessageMapper messageMapper,
+                                   QwMsgAuditPullService pullService,
+                                   List<QwMsgAuditMsgHandler> handlers) {
+        this.qwCompanyMapper = qwCompanyMapper;
+        this.seqMapper = seqMapper;
+        this.rawMapper = rawMapper;
+        this.messageMapper = messageMapper;
+        this.pullService = pullService;
+        this.handlers = handlers;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void ingestOnce(String corpId, long limit) throws Exception {
+        // 1、判断相关参数
+        QwCompany company = qwCompanyMapper.selectQwCompanyByCorpId(corpId);
+        if (company == null) {
+            log.warn("会话存档 ingest 跳过,原因:corpId不存在,corpId={}", corpId);
+            return;
+        }
+        if (company.getMsgSecret() == null || company.getMsgSecret().isEmpty()) {
+            log.warn("会话存档 ingest 跳过,原因:msgSecret为空,corpId={}", corpId);
+            return;
+        }
+        if (company.getMsgPrivateKey() == null || company.getMsgPrivateKey().isEmpty()) {
+            log.warn("会话存档 ingest 跳过,原因:msgPrivateKey为空,corpId={}", corpId);
+            return;
+        }
+
+        // 2、拉取数据
+        // 获取已成功抓取到的最大seq
+        QwMsgAuditSeq qwMsgAuditSeq = seqMapper.selectByCorpId(corpId);
+        if (qwMsgAuditSeq == null) {
+            qwMsgAuditSeq = new QwMsgAuditSeq();
+            qwMsgAuditSeq.setCorpId(corpId);
+            qwMsgAuditSeq.setSeq(0L);
+            qwMsgAuditSeq.setCreateTime(new Date());
+            qwMsgAuditSeq.setUpdateTime(new Date());
+            seqMapper.insertQwMsgAuditSeq(qwMsgAuditSeq);
+        }
+
+        long lastSeq = qwMsgAuditSeq.getSeq() == null ? 0L : qwMsgAuditSeq.getSeq();
+        List<QwMsgAuditPullService.PlainAuditMsg> batch = pullService.pullOnce(company, lastSeq, limit);
+        if (batch.isEmpty()) {
+            return;
+        }
+
+        // 3、数据解密,并存入数据库
+        for (QwMsgAuditPullService.PlainAuditMsg item : batch) {
+            Long seq = item.getSeq();
+            String plainJson = item.getPlainJson();
+
+            if (seq == null || plainJson == null || plainJson.isEmpty()) {
+                continue;
+            }
+
+            // 通过 corp_id + seq 查询唯一
+            QwMsgAuditRaw existsRaw = rawMapper.selectByCorpIdAndSeq(corpId, seq);
+            Long rawId;
+            if (existsRaw != null) {
+                rawId = existsRaw.getId();
+            } else {
+                JSONObject plain = JSON.parseObject(plainJson);
+                QwMsgAuditRaw raw = new QwMsgAuditRaw();
+                raw.setCorpId(corpId);
+                raw.setSeq(seq);
+                raw.setMsgId(plain.getString("msgid"));
+                raw.setAction(plain.getString("action"));
+                raw.setFromUser(plain.getString("from"));
+                raw.setToList(plain.getJSONArray("tolist") == null ? null : plain.getJSONArray("tolist").toJSONString());
+                raw.setRoomId(plain.getString("roomid"));
+                raw.setMsgTime(plain.getLong("msgtime"));
+                raw.setMsgType(plain.getString("msgtype"));
+                raw.setRawJson(plainJson);
+                raw.setCreateTime(new Date());
+                rawMapper.insertQwMsgAuditRaw(raw);
+                rawId = raw.getId();
+            }
+
+            // 结构化消息:暂时只处理 text/voice
+            JSONObject plain = JSON.parseObject(plainJson);
+            String msgType = plain.getString("msgtype");
+            Optional<QwMsgAuditMsgHandler> handlerOpt = handlers.stream().filter(h -> h.supports(msgType)).findFirst();
+            // 如果找不到,则跳出
+            if (!handlerOpt.isPresent()) {
+                continue;
+            }
+
+            QwMsgAuditMessage existsMsg = messageMapper.selectByCorpIdAndSeq(corpId, seq);
+            if (existsMsg != null) {
+                continue;
+            }
+            QwMsgAuditMessage msg = handlerOpt.get().buildMessage(corpId, seq, rawId, plain);
+            messageMapper.insertQwMsgAuditMessage(msg);
+        }
+
+        // 4、最后,更新当前批次最大的seq
+        Long maxSeq = batch.stream()
+                .map(QwMsgAuditPullService.PlainAuditMsg::getSeq)
+                .filter(Objects::nonNull)
+                .max(Comparator.naturalOrder())
+                .orElse(lastSeq);
+
+        seqMapper.advanceSeq(corpId, maxSeq);
+    }
+}
+

+ 66 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgAuditPullService.java

@@ -0,0 +1,66 @@
+package com.fs.app.msgarchives;
+
+import com.fs.qw.domain.QwCompany;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.cp.api.WxCpService;
+import me.chanjar.weixin.cp.bean.msgaudit.WxCpChatDatas;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 拉取并解密会话存档内容
+ * @author caoliqin
+ */
+@Service
+@Slf4j
+public class QwMsgAuditPullService {
+
+    private final QwMsgAuditWxCpFactory factory;
+
+    public QwMsgAuditPullService(QwMsgAuditWxCpFactory factory) {
+        this.factory = factory;
+    }
+
+    @Data
+    @AllArgsConstructor
+    public static class PlainAuditMsg {
+        private Long seq;
+        private String plainJson;
+    }
+
+    /**
+     * 从 lastSeq 之后拉取一批,最后返回明文 JSON列表
+     * 注意:下次拉取需要从 seq+1 开始返回
+     */
+    public List<PlainAuditMsg> pullOnce(QwCompany company, long lastSeq, long limit) throws Exception {
+        // 根据lastSeq 获取 WxCpChatDatas
+        WxCpService cpService = factory.create(company);
+        WxCpChatDatas chatDatas = cpService.getMsgAuditService().getChatDatas(lastSeq, limit, null, null, 15_000L);
+        List<PlainAuditMsg> result = new ArrayList<>();
+        if (chatDatas == null || chatDatas.getChatData() == null || chatDatas.getChatData().isEmpty()) {
+            return result;
+        }
+
+        long sdk = chatDatas.getSdk() == null ? 0L : chatDatas.getSdk();
+
+        // 遍历数据,使用PKCS8 格式的私钥解密获取明文
+        int pkcsType = 2;
+        for (Object item : chatDatas.getChatData()) {
+            WxCpChatDatas.WxCpChatData chatData = (WxCpChatDatas.WxCpChatData) item;
+            Long seq = chatData.getSeq();
+            // 使用什么方式进行解密,1代表使用PKCS1进行解密,2代表PKCS8进行解密 ...
+            String plain = cpService.getMsgAuditService().getChatPlainText(sdk, chatData, pkcsType);
+            if (plain != null && !plain.isEmpty()) {
+                result.add(new PlainAuditMsg(seq, plain));
+            } else {
+                log.warn("会话存档解密结果为空 corpId={}, seq={}", company.getCorpId(), seq);
+            }
+        }
+        return result;
+    }
+}
+

+ 56 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgAuditWxCpFactory.java

@@ -0,0 +1,56 @@
+package com.fs.app.msgarchives;
+
+import com.fs.qw.domain.QwCompany;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.cp.api.WxCpService;
+import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl;
+import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 构建会话存档客户端
+ * 按corpId+msgSecret+私钥,动态构建会话存档客户端
+ * @author caoliqin
+ */
+@Component
+@Slf4j
+public class QwMsgAuditWxCpFactory {
+
+    /**
+     * 会话存档 Finance SDK 路径(WxJava 要求)
+     */
+    @Value("${qw.msg-audit.sdk-lib-path:}")
+    private String msgAuditSdkLibPath;
+
+    public WxCpService create(QwCompany company) {
+        // 创建企微配置对象,常用
+        WxCpDefaultConfigImpl config = new WxCpDefaultConfigImpl();
+        config.setCorpId(company.getCorpId());
+
+        // 会话存档的secret用于获取access_token 以及调用 msgaudit 接口
+        // 设置企业微信的secret,确保取到正确配置
+        config.setCorpSecret(company.getMsgSecret());
+        config.setMsgAuditSecret(company.getMsgSecret());
+
+        // 解密会话存档用的私钥(PEM)
+        config.setMsgAuditPriKey(company.getMsgPrivateKey());
+
+        // agentId对会话存档拉取不是必须,但 WxCpConfigStorage 接口要求 不为 null
+        if (company.getAgentId() != null) {
+            config.setAgentId(company.getAgentId());
+        } else {
+            config.setAgentId(0);
+        }
+
+        if (!StringUtils.hasText(msgAuditSdkLibPath)) {
+            throw new IllegalStateException("未配置会话存档 Finance SDK 路径");
+        }
+        config.setMsgAuditLibPath(msgAuditSdkLibPath.trim());
+
+        WxCpService service = new WxCpServiceImpl();
+        service.setWxCpConfigStorage(config);
+        return service;
+    }
+}

+ 112 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/QwMsgMediaFileService.java

@@ -0,0 +1,112 @@
+package com.fs.app.msgarchives;
+
+import com.fs.qw.domain.QwCompany;
+import com.fs.utils.AmrToMp3Util;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
+import com.tencent.wework.Finance;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Locale;
+
+/**
+ * 企业微信会话存档:Finance 下载媒体并上传 OSS。
+ * 语音为 amr 时可先转 mp3 再上传,供豆包 ASR 使用。
+ *
+ * @author caoliqin
+ */
+@Service
+@Slf4j
+public class QwMsgMediaFileService {
+
+    @Value("${qw.msg-audit.ffmpeg-path}")
+    private String ffmpegPath;
+
+    @Value("${qw.msg-audit.voice-amr-to-mp3:true}")
+    private boolean voiceAmrToMp3Enabled;
+
+    /**
+     * 下载后上传 OSS。后缀为 .amr 且开启转换时,会先转为 mp3 再上传,返回的 URL 指向 mp3。
+     *
+     * @param company    企业信息(需要 corpId 和 msgSecret)
+     * @param sdkfileid  会话存档消息中的媒体文件id
+     * @param fileSuffix 文件后缀,由企微提供,如 .amr / .mp4 / .jpg
+     * @return OSS 存储URL,失败返回 null
+     */
+    public String downloadAndUpload(QwCompany company, String sdkfileid, String fileSuffix) {
+        if (sdkfileid == null || sdkfileid.isEmpty()) {
+            return null;
+        }
+        long sdk = Finance.NewSdk();
+        long ret = Finance.Init(sdk, company.getCorpId(), company.getMsgSecret());
+        if (ret != 0) {
+            log.error("会话存档媒体下载Init失败, ret={}, corpId={}", ret, company.getCorpId());
+            Finance.DestroySdk(sdk);
+            return null;
+        }
+
+        try {
+            byte[] fileBytes = downloadMediaBytes(sdk, sdkfileid);
+            if (fileBytes == null || fileBytes.length == 0) {
+                log.error("会话存档媒体下载数据为空, sdkfileid={}", sdkfileid);
+                return null;
+            }
+            String uploadSuffix = fileSuffix;
+
+            // 转语音格式
+            if (voiceAmrToMp3Enabled && fileSuffix != null && fileSuffix.toLowerCase(Locale.ROOT).endsWith(".amr")) {
+                try {
+                    fileBytes = AmrToMp3Util.amrBytesToMp3Bytes(fileBytes, ffmpegPath);
+                    uploadSuffix = ".mp3";
+                } catch (Exception ex) {
+                    log.error("企微语音 amr 转 mp3 失败,改为上传原始 amr, sdkfileid={}", sdkfileid, ex);
+                }
+            }
+            CloudStorageService storage = OSSFactory.build();
+            String url = storage.uploadSuffix(fileBytes, uploadSuffix);
+            log.info("会话存档媒体上传OSS成功, sdkfileid={}, suffix={}, url={}", sdkfileid, uploadSuffix, url);
+            return url;
+        } catch (Exception e) {
+            log.error("会话存档媒体上传OSS失败, sdkfileid={}", sdkfileid, e);
+            return null;
+        } finally {
+            Finance.DestroySdk(sdk);
+        }
+    }
+
+    /**
+     * 循环分片拉取媒体二进制数据,直到 isFinish
+     */
+    private byte[] downloadMediaBytes(long sdk, String sdkfileid) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        String indexbuf = "";
+        while (true) {
+            long mediaData = Finance.NewMediaData();
+            try {
+                long ret = Finance.GetMediaData(sdk, indexbuf, sdkfileid, "", "", 15L, mediaData);
+                if (ret != 0) {
+                    log.error("Finance.GetMediaData调用失败, ret={}, sdkfileid={}", ret, sdkfileid);
+                    return null;
+                }
+                byte[] chunk = Finance.GetData(mediaData);
+                if (chunk != null && chunk.length > 0) {
+                    out.write(chunk, 0, chunk.length);
+                }
+                boolean isFinish = Finance.IsMediaDataFinish(mediaData) == 1;
+                if (isFinish) {
+                    break;
+                }
+                indexbuf = Finance.GetOutIndexBuf(mediaData);
+            } catch (Exception e) {
+                log.error("Finance.GetMediaData分片处理异常, sdkfileid={}", sdkfileid, e);
+                return null;
+            } finally {
+                Finance.FreeMediaData(mediaData);
+            }
+        }
+        return out.toByteArray();
+    }
+}

+ 32 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/ImageMsgHandler.java

@@ -0,0 +1,32 @@
+package com.fs.app.msgarchives.handler;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.springframework.stereotype.Component;
+
+/**
+ * 语音消息处理
+ * @author caoliqin
+ */
+@Component
+public class ImageMsgHandler implements QwMsgAuditMsgHandler {
+    @Override
+    public boolean supports(String msgType) {
+        return "image".equals(msgType);
+    }
+
+    @Override
+    public QwMsgAuditMessage buildMessage(String corpId, Long seq, Long rawId, JSONObject plain) {
+        QwMsgAuditMessage msg = new QwMsgAuditMessage();
+        fillCommonFields(msg, corpId, seq, rawId, plain);
+
+        JSONObject image = plain.getJSONObject("image");
+        if (image != null) {
+            msg.setMediaSdkfileid(image.getString("sdkfileid"));
+            msg.setMediaMd5sum(image.getString("md5sum"));
+            msg.setMediaSize(image.getInteger("filesize"));
+        }
+        return msg;
+    }
+}
+

+ 44 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/QwMsgAuditMsgHandler.java

@@ -0,0 +1,44 @@
+package com.fs.app.msgarchives.handler;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+
+import java.util.Date;
+
+/**
+ * 会话存档-消息处理策略(按 msgtype 扩展)
+ * @author caoliqin
+ */
+public interface QwMsgAuditMsgHandler {
+
+    /**
+     * msgtype 处理类型
+     */
+    boolean supports(String msgType);
+
+    /**
+     * 各个msgtype的公共字段,放在这里统一处理,避免每个 Handler重复set
+     * @param msg 消息对象
+     * @param corpId 企微id
+     * @param seq seq
+     * @param rawId 关联raw表主键
+     * @param plain
+     */
+    default void fillCommonFields(QwMsgAuditMessage msg, String corpId, Long seq, Long rawId, JSONObject plain) {
+        msg.setCorpId(corpId);
+        msg.setSeq(seq);
+        msg.setRawId(rawId);
+        msg.setCreateTime(new Date());
+        if (plain != null) {
+            msg.setMsgId(plain.getString("msgid"));
+            msg.setMsgTime(plain.getLong("msgtime"));
+            msg.setMsgType(plain.getString("msgtype"));
+        }
+    }
+
+    /**
+     * 从明文 JSON 解析出结构化字段
+     */
+    QwMsgAuditMessage buildMessage(String corpId, Long seq, Long rawId, JSONObject plain);
+}
+

+ 32 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/TextMsgHandler.java

@@ -0,0 +1,32 @@
+package com.fs.app.msgarchives.handler;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.springframework.stereotype.Component;
+
+/**
+ * 文本消息处理
+ * @author caoliqin
+ */
+@Component
+public class TextMsgHandler implements QwMsgAuditMsgHandler {
+    @Override
+    public boolean supports(String msgType) {
+        return "text".equals(msgType);
+    }
+
+    @Override
+    public QwMsgAuditMessage buildMessage(String corpId, Long seq, Long rawId, JSONObject plain) {
+        QwMsgAuditMessage msg = new QwMsgAuditMessage();
+        // 设置公共值
+        fillCommonFields(msg, corpId, seq, rawId, plain);
+
+        JSONObject text = plain.getJSONObject("text");
+        if (text != null) {
+            msg.setTextContent(text.getString("content"));
+        }
+
+        return msg;
+    }
+}
+

+ 33 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/VideoMsgHandler.java

@@ -0,0 +1,33 @@
+package com.fs.app.msgarchives.handler;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.springframework.stereotype.Component;
+
+/**
+ * 视频消息处理
+ * @author caoliqin
+ */
+@Component
+public class VideoMsgHandler implements QwMsgAuditMsgHandler {
+    @Override
+    public boolean supports(String msgType) {
+        return "video".equals(msgType);
+    }
+
+    @Override
+    public QwMsgAuditMessage buildMessage(String corpId, Long seq, Long rawId, JSONObject plain) {
+        QwMsgAuditMessage msg = new QwMsgAuditMessage();
+        fillCommonFields(msg, corpId, seq, rawId, plain);
+
+        JSONObject video = plain.getJSONObject("video");
+        if (video != null) {
+            msg.setMediaSdkfileid(video.getString("sdkfileid"));
+            msg.setMediaMd5sum(video.getString("md5sum"));
+            msg.setMediaSize(video.getInteger("filesize"));
+            msg.setMediaPlayLength(video.getInteger("play_length"));
+        }
+        return msg;
+    }
+}
+

+ 33 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/VoiceMsgHandler.java

@@ -0,0 +1,33 @@
+package com.fs.app.msgarchives.handler;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.springframework.stereotype.Component;
+
+/**
+ * 语音消息处理
+ * @author caoliqin
+ */
+@Component
+public class VoiceMsgHandler implements QwMsgAuditMsgHandler {
+    @Override
+    public boolean supports(String msgType) {
+        return "voice".equals(msgType);
+    }
+
+    @Override
+    public QwMsgAuditMessage buildMessage(String corpId, Long seq, Long rawId, JSONObject plain) {
+        QwMsgAuditMessage msg = new QwMsgAuditMessage();
+        fillCommonFields(msg, corpId, seq, rawId, plain);
+
+        JSONObject voice = plain.getJSONObject("voice");
+        if (voice != null) {
+            msg.setMediaSdkfileid(voice.getString("sdkfileid"));
+            msg.setMediaMd5sum(voice.getString("md5sum"));
+            msg.setMediaSize(voice.getInteger("voice_size"));
+            msg.setMediaPlayLength(voice.getInteger("play_length"));
+        }
+        return msg;
+    }
+}
+

+ 118 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/job/QwMsgAuditScheduleJob.java

@@ -0,0 +1,118 @@
+package com.fs.app.msgarchives.job;
+
+import com.fs.aiSoundReplication.service.AsrService;
+import com.fs.app.msgarchives.QwMsgAuditIngestService;
+import com.fs.app.msgarchives.QwMsgMediaFileService;
+import com.fs.enums.MediaMsgTypeEnum;
+import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import com.fs.qw.mapper.QwCompanyMapper;
+import com.fs.qw.mapper.QwMsgAuditMessageMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+/**
+ * 定时拉取会话存档消息并存库;
+ * 定时补充媒体文件(语音/视频/图片)OSS 上传;
+ */
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class QwMsgAuditScheduleJob {
+
+    /** 每次每种类型最多处理的条数,避免单次任务耗时过长 */
+    private static final int MEDIA_BATCH_LIMIT = 50;
+
+    private final QwCompanyMapper qwCompanyMapper;
+    private final QwMsgAuditIngestService ingestService;
+    private final QwMsgAuditMessageMapper messageMapper;
+    private final QwMsgMediaFileService mediaFileService;
+    private final AsrService asrService;
+
+    /**
+     * 每1分钟执行一次,拉取会话存档消息并存库
+     */
+    @Scheduled(cron = "0 */1 * * * ?")
+    public void pullQwMsgAndStore() {
+        List<String> corpIds = qwCompanyMapper.selectQwCompanyCorpIdListByAll();
+        if (corpIds == null || corpIds.isEmpty()) {
+            return;
+        }
+        for (String corpId : corpIds) {
+            try {
+                ingestService.ingestOnce(corpId, 1000);
+            } catch (Exception e) {
+                log.error("会话存档 ingest失败,corpId={}", corpId, e);
+            }
+        }
+    }
+
+    /**
+     * 每2分钟执行一次,扫描 media_oss_url为空的媒体消息(语音/视频/图片),下载并上传至 OSS
+     */
+    @Scheduled(cron = "0 */2 * * * ?")
+    public void uploadMediaToOss() {
+        List<String> corpIds = qwCompanyMapper.selectQwCompanyCorpIdListByAll();
+        if (corpIds == null || corpIds.isEmpty()) {
+            return;
+        }
+        for (String corpId : corpIds) {
+            QwCompany company = qwCompanyMapper.selectQwCompanyByCorpId(corpId);
+            if (company == null || company.getMsgSecret() == null || company.getMsgSecret().isEmpty()) {
+                log.warn("媒体OSS上传跳过,corpId={} 企业信息不完整", corpId);
+                continue;
+            }
+            for (MediaMsgTypeEnum type : MediaMsgTypeEnum.values()) {
+                try {
+                    processCorpMedia(company, type);
+                } catch (Exception e) {
+                    log.error("媒体OSS上传任务异常, corpId={}, type={}", corpId, type.getMsgType(), e);
+                }
+            }
+        }
+    }
+
+    private void processCorpMedia(QwCompany company, MediaMsgTypeEnum type) {
+        List<QwMsgAuditMessage> pendingList = messageMapper.selectMediaPendingOssUpload(
+                company.getCorpId(), type.getMsgType(), MEDIA_BATCH_LIMIT);
+        if (pendingList == null || pendingList.isEmpty()) {
+            return;
+        }
+        log.info("媒体OSS上传开始, corpId={}, type={}, 待处理条数={}", company.getCorpId(), type.getMsgType(), pendingList.size());
+        int successCount = 0;
+        for (QwMsgAuditMessage msg : pendingList) {
+            String ossUrl = mediaFileService.downloadAndUpload(company, msg.getMediaSdkfileid(), type.getFileSuffix());
+            if (ossUrl != null) {
+                msg.setMediaOssUrl(ossUrl);
+                messageMapper.updateQwMsgAuditMessage(msg);
+                successCount++;
+
+                //将语音转文字的内容存入数据库
+                if (MediaMsgTypeEnum.VOICE.equals(type)) {
+                    transcribeVoiceToDb(msg);
+                }
+            }
+        }
+        log.info("媒体OSS上传完成, corpId={}, type={}, 成功/总条数={}/{}", company.getCorpId(), type.getMsgType(), successCount, pendingList.size());
+    }
+
+    /**
+     * 语音上传 OSS 后调用豆包 ASR,将识别结果更新到表
+     */
+    private void transcribeVoiceToDb(QwMsgAuditMessage msg) {
+        try {
+            String text = asrService.recognizeFromUrl(msg.getMediaOssUrl());
+            if (StringUtils.hasText(text)) {
+                msg.setTextContent(text);
+                messageMapper.updateQwMsgAuditMessage(msg);
+            }
+        } catch (Exception e) {
+            log.warn("语音转文字失败, msgId={}, id={}", msg.getMsgId(), msg.getId(), e);
+        }
+    }
+}

+ 31 - 4
fs-qw-api-msg/src/main/java/com/tencent/wework/Finance.java

@@ -1,5 +1,8 @@
 package com.tencent.wework;
 
+import java.io.File;
+import java.util.List;
+
 /* sdk
 typedef struct Slice_t {
     char* buf;
@@ -16,6 +19,34 @@ typedef struct MediaData {
 */
 
 public class Finance {
+
+    /**
+     * WxJava 4.x 会话存档拉取前会调用;按 msgAuditLibPath 显式加载 dll。
+     */
+    public static Finance loadingLibraries(List<String> libraries, String libDirOrFirstLibPath) {
+        if (libraries == null || libraries.isEmpty()) {
+            return new Finance();
+        }
+        final File base;
+        if (libDirOrFirstLibPath == null || libDirOrFirstLibPath.trim().isEmpty()) {
+            base = null;
+        } else {
+            File f = new File(libDirOrFirstLibPath);
+            base = f.isFile() ? f.getParentFile() : f;
+        }
+        for (String lib : libraries) {
+            if (lib == null || lib.trim().isEmpty()) {
+                continue;
+            }
+            File libFile = new File(lib);
+            if (!libFile.isAbsolute() && base != null) {
+                libFile = new File(base, lib);
+            }
+            System.load(libFile.getAbsolutePath());
+        }
+        return new Finance();
+    }
+
     public native static long NewSdk();
 
     /**
@@ -121,8 +152,4 @@ public class Finance {
      * @brief �ж�mediadata�Ƿ����
      */
     public native static int IsMediaDataFinish(long mediaData);
-
-    static {
-        System.loadLibrary("WeWorkFinanceSdk");
-    }
 }

+ 56 - 0
fs-service/src/main/java/com/fs/aiSoundReplication/config/AsrConfig.java

@@ -0,0 +1,56 @@
+package com.fs.aiSoundReplication.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 豆包「大模型录音文件识别」HTTP 配置
+ * @author caoliqin
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "doubao.asr")
+public class AsrConfig {
+
+    private String submitUrl = "https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit";
+    private String queryUrl = "https://openspeech.bytedance.com/api/v3/auc/bigmodel/query";
+
+    private String appKey;
+
+    private String accessToken;
+
+    /** 新版仅填 X-Api-Key 时配置此项,非空则不再使用 appKey、accessToken */
+    private String apiKey;
+
+    /** 模型资源:1.0 volc.bigasr.auc,2.0 volc.seedasr.auc */
+    private String resourceId = "volc.seedasr.auc";
+
+    /** 标识为企微会话存档 */
+    private String uid = "fs-msg-audit";
+
+    /** 须与音频 URL 实际格式一致:raw/wav/mp3/ogg;企微 amr 需先转码 */
+    private String defaultAudioFormat = "mp3";
+
+    private String defaultLanguage = "zh-CN";
+
+    private boolean enableItn = true;
+    private boolean enablePunc = true;
+
+    private long pollIntervalMs = 2000L;
+    private int maxQueryAttempts = 90;
+
+    private boolean enabled = true;
+
+    public boolean useNewConsoleApiKey() {
+        return apiKey != null && !apiKey.trim().isEmpty();
+    }
+
+    public boolean hasCredentials() {
+        if (useNewConsoleApiKey()) {
+            return true;
+        }
+        return appKey != null && !appKey.trim().isEmpty()
+                && accessToken != null && !accessToken.trim().isEmpty();
+    }
+}

+ 15 - 0
fs-service/src/main/java/com/fs/aiSoundReplication/service/AsrService.java

@@ -0,0 +1,15 @@
+package com.fs.aiSoundReplication.service;
+
+/**
+ * 豆包:公网音频 URL 转写为文本。实现类会读 doubao.asr 配置。
+ * 用法:注入 AsrService,调用 recognizeFromUrl(String);
+ * 密钥与 URL 见AsrConfig配置
+ */
+public interface AsrService {
+
+    /** 使用配置中的默认 format、language */
+    String recognizeFromUrl(String audioUrl);
+
+    /** 指定容器格式与语言,如"mp3","zh-CN" */
+    String recognizeFromUrl(String audioUrl, String format, String language);
+}

+ 192 - 0
fs-service/src/main/java/com/fs/aiSoundReplication/service/impl/AsrServiceImpl.java

@@ -0,0 +1,192 @@
+package com.fs.aiSoundReplication.service.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fs.aiSoundReplication.config.AsrConfig;
+import com.fs.aiSoundReplication.service.AsrService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 豆包大模型录音文件识别
+ * 状态码见官方文档
+ */
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class AsrServiceImpl implements AsrService {
+
+    private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+    private static final String STATUS_OK = "20000000";
+    private static final String STATUS_PROCESSING = "20000001";
+    private static final String STATUS_QUEUED = "20000002";
+
+    private final AsrConfig config;
+    private final OkHttpClient okHttpClient;
+    private final ObjectMapper objectMapper;
+
+    @Override
+    public String recognizeFromUrl(String audioUrl) {
+        return recognizeFromUrl(audioUrl, config.getDefaultAudioFormat(), config.getDefaultLanguage());
+    }
+
+    @Override
+    public String recognizeFromUrl(String audioUrl, String format, String language) {
+        if (!config.isEnabled() || !config.hasCredentials()) {
+            log.warn("ASR 未启用或未配置 doubao.asr,跳过");
+            return null;
+        }
+        if (!StringUtils.hasText(audioUrl)) {
+            return null;
+        }
+        String fmt = StringUtils.hasText(format) ? format : config.getDefaultAudioFormat();
+        String taskId = UUID.randomUUID().toString();
+        try {
+            if (!submitTask(taskId, audioUrl, fmt, language)) {
+                return null;
+            }
+            return pollQuery(taskId);
+        } catch (Exception e) {
+            log.error("ASR 识别异常, url={}", audioUrl, e);
+            return null;
+        }
+    }
+
+    private boolean submitTask(String taskId, String audioUrl, String format, String language) throws Exception {
+        Map<String, Object> user = new HashMap<>();
+        user.put("uid", config.getUid());
+
+        Map<String, Object> audio = new HashMap<>();
+        audio.put("url", audioUrl);
+        audio.put("format", format);
+        if (StringUtils.hasText(language)) {
+            audio.put("language", language);
+        }
+
+        Map<String, Object> req = new HashMap<>();
+        req.put("model_name", "bigmodel");
+        req.put("enable_itn", config.isEnableItn());
+        req.put("enable_punc", config.isEnablePunc());
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("user", user);
+        body.put("audio", audio);
+        body.put("request", req);
+
+        String json = objectMapper.writeValueAsString(body);
+        Request.Builder builder = new Request.Builder()
+                .url(config.getSubmitUrl())
+                .post(RequestBody.create(json, JSON))
+                .addHeader("Content-Type", "application/json");
+
+        applySubmitHeaders(builder, taskId);
+
+        try (Response response = okHttpClient.newCall(builder.build()).execute()) {
+            String code = headerStatus(response, "X-Api-Status-Code");
+            if (STATUS_OK.equals(code)) {
+                log.info("ASR submit 成功, taskId={}, logId={}", taskId, response.header("X-Tt-Logid"));
+                return true;
+            }
+            log.error("ASR submit 失败, taskId={}, status={}, message={}, body={}",
+                    taskId, code, response.header("X-Api-Message"), bodyString(response));
+            return false;
+        }
+    }
+
+    private String pollQuery(String taskId) throws Exception {
+        Request.Builder builder = new Request.Builder()
+                .url(config.getQueryUrl())
+                .post(RequestBody.create("{}", JSON))
+                .addHeader("Content-Type", "application/json");
+
+        applyQueryHeaders(builder, taskId);
+
+        Request request = builder.build();
+        for (int i = 0; i < config.getMaxQueryAttempts(); i++) {
+            try (Response response = okHttpClient.newCall(request).execute()) {
+                String code = headerStatus(response, "X-Api-Status-Code");
+                String bodyStr = bodyString(response);
+
+                if (STATUS_OK.equals(code)) {
+                    String text = extractText(bodyStr);
+                    if (StringUtils.hasText(text)) {
+                        return text.trim();
+                    }
+                    log.warn("ASR query 成功但 result.text 为空, taskId={}", taskId);
+                    return null;
+                }
+                if (STATUS_PROCESSING.equals(code) || STATUS_QUEUED.equals(code)) {
+                    Thread.sleep(config.getPollIntervalMs());
+                    continue;
+                }
+                log.error("ASR query 失败, taskId={}, status={}, message={}, body={}",
+                        taskId, code, response.header("X-Api-Message"), bodyStr);
+                return null;
+            }
+        }
+        log.error("ASR query 超时, taskId={}, attempts={}", taskId, config.getMaxQueryAttempts());
+        return null;
+    }
+
+    private void applySubmitHeaders(Request.Builder builder, String taskId) {
+        builder.addHeader("X-Api-Resource-Id", config.getResourceId());
+        builder.addHeader("X-Api-Request-Id", taskId);
+        if (config.useNewConsoleApiKey()) {
+            builder.addHeader("X-Api-Key", config.getApiKey());
+            builder.addHeader("X-Api-Sequence", "-1");
+        } else {
+            builder.addHeader("X-Api-App-Key", config.getAppKey());
+            builder.addHeader("X-Api-Access-Key", config.getAccessToken());
+            builder.addHeader("X-Api-Sequence", "-1");
+        }
+    }
+
+    private void applyQueryHeaders(Request.Builder builder, String taskId) {
+        builder.addHeader("X-Api-Resource-Id", config.getResourceId());
+        builder.addHeader("X-Api-Request-Id", taskId);
+        if (config.useNewConsoleApiKey()) {
+            builder.addHeader("X-Api-Key", config.getApiKey());
+        } else {
+            builder.addHeader("X-Api-App-Key", config.getAppKey());
+            builder.addHeader("X-Api-Access-Key", config.getAccessToken());
+        }
+    }
+
+    private static String headerStatus(Response response, String name) {
+        String v = response.header(name);
+        if (v == null) {
+            v = response.header(name.toLowerCase());
+        }
+        return v != null ? v.trim() : "";
+    }
+
+    private static String bodyString(Response response) throws java.io.IOException {
+        if (response.body() == null) {
+            return "";
+        }
+        return response.body().string();
+    }
+
+    private String extractText(String json) throws Exception {
+        if (!StringUtils.hasText(json)) {
+            return null;
+        }
+        JsonNode root = objectMapper.readTree(json);
+        JsonNode text = root.path("result").path("text");
+        if (text.isMissingNode() || text.isNull()) {
+            return null;
+        }
+        return text.asText();
+    }
+}

+ 34 - 0
fs-service/src/main/java/com/fs/enums/MediaMsgTypeEnum.java

@@ -0,0 +1,34 @@
+package com.fs.enums;
+
+import lombok.Getter;
+
+/**
+ * 企业微信会话存档 - 需要下载并上传至 OSS 的媒体消息类型
+ */
+@Getter
+public enum MediaMsgTypeEnum {
+
+    /**
+     * 语音
+     */
+    VOICE("voice", ".amr"),
+    /**
+     * 视频
+     */
+    VIDEO("video", ".mp4"),
+    /**
+     * 图片
+     */
+    IMAGE("image", ".jpg");
+
+    /** 企微消息中的 msgtype 字段值 */
+    private final String msgType;
+
+    /** 上传 OSS 时使用的文件后缀 */
+    private final String fileSuffix;
+
+    MediaMsgTypeEnum(String msgType, String fileSuffix) {
+        this.msgType = msgType;
+        this.fileSuffix = fileSuffix;
+    }
+}

+ 13 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -16,6 +16,7 @@ import com.fs.his.param.FsUserParam;
 import com.fs.his.vo.AppSalesCourseStatisticsVO;
 import com.fs.his.vo.FsUserVO;
 import com.fs.his.vo.FsUserExportListVO;
+import com.fs.his.vo.FsUserWatchDaysStatVO;
 import com.fs.his.vo.OptionsVO;
 import com.fs.hisStore.vo.FsCompanyUserListQueryVO;
 import com.fs.qw.dto.FsUserTransferParamDTO;
@@ -490,6 +491,18 @@ public interface FsUserMapper
      * **/
     List<FsUser> selectUserListByUserIds(@Param("userIds") List<Long> userIds);
 
+    /**
+     * 去重查询以会员为维度的会员列表
+     * @param fsUser 参数
+     * @return
+     */
+    List<FsUserVO> selectStatisticsList(FsUser fsUser);
+
+    /**
+     * 按公司 + 当页用户,查询最近3/5/7自然日看课天数(去重按DATE(last_heartbeat_time))
+     */
+    List<FsUserWatchDaysStatVO> selectWatchDaysStatByUserIds(@Param("companyId") Long companyId, @Param("userIds") List<Long> userIds);
+
     List<AppSalesCourseStatisticsVO> selectAppSalesUserCountVO(FsCourseWatchLogStatisticsListParam param);
 
     List<AppSalesCourseStatisticsVO> selectAppSalesNewUserCountVO(FsCourseWatchLogStatisticsListParam param);

+ 3 - 0
fs-service/src/main/java/com/fs/his/service/IFsUserService.java

@@ -245,4 +245,7 @@ public interface IFsUserService
      * @return List<FsUser>
      * **/
     List<FsUser> selectUserListByUserIds(List<Long> userIds);
+
+    List<FsUserVO> selectStatisticsList(FsUser fsUser);
+
 }

+ 37 - 4
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -55,10 +55,7 @@ import com.fs.his.service.IFsUserIntegralLogsService;
 import com.fs.his.service.IFsUserProjectTagService;
 import com.fs.his.service.IFsUserWxService;
 import com.fs.his.utils.PhoneUtil;
-import com.fs.his.vo.FsUserVO;
-import com.fs.his.vo.FsUserExportListVO;
-import com.fs.his.vo.FsUserFollowDoctorVO;
-import com.fs.his.vo.UserVo;
+import com.fs.his.vo.*;
 import com.fs.im.config.ImTypeConfig;
 import com.fs.im.service.OpenIMService;
 import com.fs.hisStore.domain.FsStoreOrderScrm;
@@ -1626,4 +1623,40 @@ public class FsUserServiceImpl implements IFsUserService {
     public List<FsUser> selectUserListByUserIds(List<Long> userIds) {
         return fsUserMapper.selectUserListByUserIds(userIds);
     }
+
+    @Override
+    public List<FsUserVO> selectStatisticsList(FsUser fsUser) {
+        // 查询会员
+        List<FsUserVO> list = fsUserMapper.selectStatisticsList(fsUser);
+        if (list == null || list.isEmpty()) {
+            return list;
+        }
+
+        List<Long> userIds = list.stream().map(FsUserVO::getUserId).collect(Collectors.toList());
+        if (userIds.isEmpty()) {
+            return list;
+        }
+
+        // 将数据以userid为key放入map中
+        List<FsUserWatchDaysStatVO> watchNum = fsUserMapper.selectWatchDaysStatByUserIds(fsUser.getCompanyId(), userIds);
+        Map<Long, FsUserWatchDaysStatVO> map = new HashMap<>();
+        if (watchNum != null) {
+            for (FsUserWatchDaysStatVO s : watchNum) {
+                if (s != null && s.getUserId() != null) {
+                    map.put(s.getUserId(), s);
+                }
+            }
+        }
+
+        list.stream()
+                .filter(Objects::nonNull)
+                .filter(vo -> vo.getUserId() != null)
+                .forEach(vo -> {
+                    FsUserWatchDaysStatVO s = map.get(vo.getUserId());
+                    vo.setWatchDaysLast3(s == null || s.getWatchDaysLast3() == null ? 0 : s.getWatchDaysLast3());
+                    vo.setWatchDaysLast5(s == null || s.getWatchDaysLast5() == null ? 0 : s.getWatchDaysLast5());
+                    vo.setWatchDaysLast7(s == null || s.getWatchDaysLast7() == null ? 0 : s.getWatchDaysLast7());
+                });
+        return list;
+    }
 }

+ 9 - 0
fs-service/src/main/java/com/fs/his/vo/FsUserVO.java

@@ -174,4 +174,13 @@ public class FsUserVO extends FsUser implements Serializable
 
     @ApiModelProperty(value = "app来源")
     private String source;
+
+    @ApiModelProperty(value = "最近3天看课天数")
+    private Integer watchDaysLast3;
+
+    @ApiModelProperty(value = "最近5天看课天数")
+    private Integer watchDaysLast5;
+
+    @ApiModelProperty(value = "最近7天看课天数")
+    private Integer watchDaysLast7;
 }

+ 15 - 0
fs-service/src/main/java/com/fs/his/vo/FsUserWatchDaysStatVO.java

@@ -0,0 +1,15 @@
+package com.fs.his.vo;
+
+import lombok.Data;
+
+/**
+ * 会员看课天数统计(按自然日、last_heartbeat_time)
+ */
+@Data
+public class FsUserWatchDaysStatVO {
+    private Long userId;
+    private Integer watchDaysLast3;
+    private Integer watchDaysLast5;
+    private Integer watchDaysLast7;
+}
+

+ 48 - 0
fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditMessage.java

@@ -0,0 +1,48 @@
+package com.fs.qw.domain.audit;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 企业微信会话存档结构化消息 qw_msg_audit_message
+ */
+@Data
+public class QwMsgAuditMessage {
+    private Long id;
+    private String corpId;
+    private Long seq;
+    private String msgId;
+    private Long msgTime;
+    private String msgType;
+
+    /**
+     * text.content
+     */
+    private String textContent;
+
+    /**
+     * 媒体通用字段(语音/图片/视频/表情等),字段名与表 media_sdkfileid / media_md5sum 及 Mapper 一致
+     */
+    private String mediaSdkfileid;
+    private String mediaMd5sum;
+    private Integer mediaSize;
+    private Integer mediaPlayLength;
+    private Integer mediaWidth;
+    private Integer mediaHeight;
+    private String mediaFileName;
+    private String mediaFileExt;
+
+    /**
+     * 媒体文件上传至 OSS 后的访问 URL(语音/视频)
+     */
+    private String mediaOssUrl;
+
+    /**
+     * 关联原始表
+     */
+    private Long rawId;
+
+    private Date createTime;
+}
+

+ 31 - 0
fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditRaw.java

@@ -0,0 +1,31 @@
+package com.fs.qw.domain.audit;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 企业微信会话存档原始明文(解密后) qw_msg_audit_raw
+ */
+@Data
+public class QwMsgAuditRaw {
+    private Long id;
+    private String corpId;
+    private Long seq;
+    private String msgId;
+    private String action;
+    private String fromUser;
+    /**
+     * 逗号分隔或 JSON 字符串(按现有项目风格存 String)
+     */
+    private String toList;
+    private String roomId;
+    private Long msgTime;
+    private String msgType;
+    /**
+     * 解密后的完整 JSON
+     */
+    private String rawJson;
+    private Date createTime;
+}
+

+ 21 - 0
fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditSeq.java

@@ -0,0 +1,21 @@
+package com.fs.qw.domain.audit;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 企业微信会话存档拉取进度(seq) qw_msg_audit_seq
+ */
+@Data
+public class QwMsgAuditSeq {
+    private Long id;
+    private String corpId;
+    /**
+     * 已成功处理到的最大 seq
+     */
+    private Long seq;
+    private Date createTime;
+    private Date updateTime;
+}
+

+ 75 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditMessageMapper.java

@@ -0,0 +1,75 @@
+package com.fs.qw.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 企微会话存档结构化消息Mapper接口
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+public interface QwMsgAuditMessageMapper extends BaseMapper<QwMsgAuditMessage>{
+    /**
+     * 查询企微会话存档结构化消息
+     *
+     * @param id 企微会话存档结构化消息主键
+     * @return 企微会话存档结构化消息
+     */
+    QwMsgAuditMessage selectQwMsgAuditMessageById(Long id);
+
+    /**
+     * 查询企微会话存档结构化消息列表
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 企微会话存档结构化消息集合
+     */
+    List<QwMsgAuditMessage> selectQwMsgAuditMessageList(QwMsgAuditMessage qwMsgAuditMessage);
+
+    /**
+     * 新增企微会话存档结构化消息
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 结果
+     */
+    int insertQwMsgAuditMessage(QwMsgAuditMessage qwMsgAuditMessage);
+
+    /**
+     * 修改企微会话存档结构化消息
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 结果
+     */
+    int updateQwMsgAuditMessage(QwMsgAuditMessage qwMsgAuditMessage);
+
+    /**
+     * 删除企微会话存档结构化消息
+     *
+     * @param id 企微会话存档结构化消息主键
+     * @return 结果
+     */
+    int deleteQwMsgAuditMessageById(Long id);
+
+    /**
+     * 批量删除企微会话存档结构化消息
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteQwMsgAuditMessageByIds(Long[] ids);
+
+    @Select("select * from qw_msg_audit_message where corp_id = #{corpId} and seq = #{seq} limit 1")
+    QwMsgAuditMessage selectByCorpIdAndSeq(@Param("corpId") String corpId, @Param("seq") Long seq);
+
+    /**
+     * 查询指定企业下指定消息类型中 media_oss_url 为空的记录(待下载上传)
+     */
+    @Select("select * from qw_msg_audit_message where corp_id = #{corpId} and msg_type = #{msgType} and media_sdkfileid is not null and media_oss_url is null limit #{limit}")
+    List<QwMsgAuditMessage> selectMediaPendingOssUpload(@Param("corpId") String corpId, @Param("msgType") String msgType, @Param("limit") int limit);
+
+//    int batchInsertMessage(QwMsgAuditMessage record);
+
+}

+ 69 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditRawMapper.java

@@ -0,0 +1,69 @@
+package com.fs.qw.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.qw.domain.audit.QwMsgAuditRaw;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 企微会话存档原始明文Mapper接口
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+public interface QwMsgAuditRawMapper extends BaseMapper<QwMsgAuditRaw>{
+    /**
+     * 查询企微会话存档原始明文
+     *
+     * @param id 企微会话存档原始明文主键
+     * @return 企微会话存档原始明文
+     */
+    QwMsgAuditRaw selectQwMsgAuditRawById(Long id);
+
+    /**
+     * 查询企微会话存档原始明文列表
+     *
+     * @param qwMsgAuditRaw 企微会话存档原始明文
+     * @return 企微会话存档原始明文集合
+     */
+    List<QwMsgAuditRaw> selectQwMsgAuditRawList(QwMsgAuditRaw qwMsgAuditRaw);
+
+    /**
+     * 新增企微会话存档原始明文
+     *
+     * @param qwMsgAuditRaw 企微会话存档原始明文
+     * @return 结果
+     */
+    int insertQwMsgAuditRaw(QwMsgAuditRaw qwMsgAuditRaw);
+
+    /**
+     * 修改企微会话存档原始明文
+     *
+     * @param qwMsgAuditRaw 企微会话存档原始明文
+     * @return 结果
+     */
+    int updateQwMsgAuditRaw(QwMsgAuditRaw qwMsgAuditRaw);
+
+    /**
+     * 删除企微会话存档原始明文
+     *
+     * @param id 企微会话存档原始明文主键
+     * @return 结果
+     */
+    int deleteQwMsgAuditRawById(Long id);
+
+    /**
+     * 批量删除企微会话存档原始明文
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteQwMsgAuditRawByIds(Long[] ids);
+
+    @Select("select * from qw_msg_audit_raw where corp_id = #{corpId} and seq = #{seq} limit 1")
+    QwMsgAuditRaw selectByCorpIdAndSeq(@Param("corpId") String corpId, @Param("seq") Long seq);
+
+//    int batchInsertRaw(QwMsgAuditRaw record);
+
+}

+ 75 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditSeqMapper.java

@@ -0,0 +1,75 @@
+package com.fs.qw.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.qw.domain.audit.QwMsgAuditSeq;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 企微会话存档拉取进度(seq)Mapper接口
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+public interface QwMsgAuditSeqMapper extends BaseMapper<QwMsgAuditSeq>{
+    /**
+     * 查询企微会话存档拉取进度(seq)
+     *
+     * @param id 企微会话存档拉取进度(seq)主键
+     * @return 企微会话存档拉取进度(seq)
+     */
+    QwMsgAuditSeq selectQwMsgAuditSeqById(Long id);
+
+    /**
+     * 查询企微会话存档拉取进度(seq)列表
+     *
+     * @param qwMsgAuditSeq 企微会话存档拉取进度(seq)
+     * @return 企微会话存档拉取进度(seq)集合
+     */
+    List<QwMsgAuditSeq> selectQwMsgAuditSeqList(QwMsgAuditSeq qwMsgAuditSeq);
+
+    /**
+     * 新增企微会话存档拉取进度(seq)
+     *
+     * @param qwMsgAuditSeq 企微会话存档拉取进度(seq)
+     * @return 结果
+     */
+    int insertQwMsgAuditSeq(QwMsgAuditSeq qwMsgAuditSeq);
+
+    /**
+     * 修改企微会话存档拉取进度(seq)
+     *
+     * @param qwMsgAuditSeq 企微会话存档拉取进度(seq)
+     * @return 结果
+     */
+    int updateQwMsgAuditSeq(QwMsgAuditSeq qwMsgAuditSeq);
+
+    /**
+     * 删除企微会话存档拉取进度(seq)
+     *
+     * @param id 企微会话存档拉取进度(seq)主键
+     * @return 结果
+     */
+    int deleteQwMsgAuditSeqById(Long id);
+
+    /**
+     * 批量删除企微会话存档拉取进度(seq)
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteQwMsgAuditSeqByIds(Long[] ids);
+
+    @Select("select * from qw_msg_audit_seq where corp_id = #{corpId} limit 1")
+    QwMsgAuditSeq selectByCorpId(@Param("corpId") String corpId);
+
+    /**
+     * 更新seq, 如果 seq 值小于等于 newSeq,则更新
+     * @param corpId 企微id
+     * @param newSeq 新的 seq
+     * @return
+     */
+    int advanceSeq(@Param("corpId") String corpId, @Param("newSeq") Long newSeq);
+
+}

+ 82 - 0
fs-service/src/main/java/com/fs/utils/AmrToMp3Util.java

@@ -0,0 +1,82 @@
+package com.fs.utils;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * 企微 Finance 下载的语音为 amr,豆包 ASR 要求 mp3/wav/ogg 等,用本机已安装的 ffmpeg 做转换。
+ * 需要本地有 ffmpeg
+ */
+@Slf4j
+public final class AmrToMp3Util {
+
+    private AmrToMp3Util() {
+    }
+
+    /**
+     * amr 字节转 mp3 字节。服务器需已安装 ffmpeg 并在 PATH 中,或通过参数传入绝对路径。
+     *
+     * @param amrBytes         原始 amr
+     * @param ffmpegExecutable 如 ffmpeg 或 D:/tools/ffmpeg.exe
+     */
+    public static byte[] amrBytesToMp3Bytes(byte[] amrBytes, String ffmpegExecutable) throws Exception {
+        if (amrBytes == null || amrBytes.length == 0) {
+            throw new IllegalArgumentException("amr empty");
+        }
+        Path in = Files.createTempFile("qw_amr_", ".amr");
+        Path out = Files.createTempFile("qw_mp3_", ".mp3");
+        try {
+            Files.write(in, amrBytes);
+            ProcessBuilder pb = new ProcessBuilder(
+                    ffmpegExecutable,
+                    "-y",
+                    "-i", in.toAbsolutePath().toString(),
+                    "-vn", // 表示不处理视频
+                    "-acodec", "libmp3lame", //指定 MP3 编码器
+                    "-q:a", "4", //控制音质,数值越小质量越高
+                    out.toAbsolutePath().toString()
+            );
+            pb.redirectErrorStream(true);
+            Process p = pb.start();
+            try (InputStream err = p.getInputStream()) {
+                drainToLog(err);
+            }
+            int code = p.waitFor();
+            if (code != 0) {
+                throw new IllegalStateException("ffmpeg exit=" + code);
+            }
+            return Files.readAllBytes(out);
+        } finally {
+            try {
+                Files.deleteIfExists(in);
+            } catch (Exception ignored) {
+            }
+            try {
+                Files.deleteIfExists(out);
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    private static void drainToLog(InputStream in) throws java.io.IOException {
+        byte[] buf = new byte[8192];
+        int n;
+        ByteArrayOutputStream acc = new ByteArrayOutputStream();
+        while ((n = in.read(buf)) != -1) {
+            acc.write(buf, 0, n);
+        }
+        byte[] all = acc.toByteArray();
+        if (all.length > 0 && log.isDebugEnabled()) {
+            String s = new String(all, StandardCharsets.UTF_8);
+            if (s.length() > 2000) {
+                s = s.substring(0, 2000) + "...";
+            }
+            log.debug("ffmpeg: {}", s);
+        }
+    }
+}

+ 15 - 0
fs-service/src/main/resources/application-config-dev.yml

@@ -100,6 +100,21 @@ cloud_host:
   projectCode: DEV
   spaceName:
   volcengineUrl:
+
+# 豆包语音 - 大模型录音文件识别
+doubao:
+  asr:
+    enabled: true
+    # 旧版控制台:APP ID -> 请求头 X-Api-App-Key
+    app-key: 9901314779
+    # 旧版控制台:Access Token -> 请求头 X-Api-Access-Key
+    access-token: ahvjLYU_CX86otk0ffpkQTGjQPz_iOvw
+    # 新版控制台:仅使用 API Key 时配置此项,并留空 app-key、access-token
+    # api-key: ''
+    resource-id: volc.seedasr.auc
+    default-audio-format: mp3
+    default-language: zh-CN
+
 headerImg:
   imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
 ipad:

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

@@ -89,6 +89,21 @@ cloud_host:
   projectCode: DDGY
   spaceName: ddgy-2114522511
   volcengineUrl: https://ddgyvolcengine.ylrztop.com
+
+# 豆包语音 - 大模型录音文件识别
+doubao:
+  asr:
+    enabled: true
+    # 旧版控制台:APP ID -> 请求头 X-Api-App-Key
+    app-key: 9901314779
+    # 旧版控制台:Access Token -> 请求头 X-Api-Access-Key
+    access-token: ahvjLYU_CX86otk0ffpkQTGjQPz_iOvw
+    # 新版控制台:仅使用 API Key 时配置此项,并留空 app-key、access-token
+    # api-key: ''
+    resource-id: volc.seedasr.auc
+    default-audio-format: mp3
+    default-language: zh-CN
+
 #看课授权时显示的头像
 headerImg:
   imgUrl: https://ddgy-1323137866.cos.ap-chongqing.myqcloud.com/fs/20251010/ddgy.jpg
@@ -102,3 +117,13 @@ wx_miniapp_temp:
   pay_order_temp_id:
   inquiry_temp_id:
 
+sysconfig:
+  # 包含哪些关键词的配置文件参数,将会被mask打码隐藏
+  hidden-key-list: api/app/token/key/secret/access
+  # 是否开启敏感参数隐藏功能
+  hide-secret: true
+  # 系统版本号
+  sysVersion: v20260217
+  # 是否开启登陆时选择业务组
+  show-dynamic-groupid: true
+

+ 1 - 1
fs-service/src/main/resources/application-druid-hsyy.yml

@@ -163,7 +163,7 @@ token:
 openIM:
     secret: openIM123
     userID: imAdmin
-    url: https://web.im.ysya.top/api
+    url: https://webim.hshsyy.com/api
 #是否使用新im
 im:
     type: OPENIM

+ 2 - 2
fs-service/src/main/resources/application-druid-mengniu.yml

@@ -168,10 +168,10 @@ token:
 openIM:
     secret: openIM123
     userID: imAdmin
-    url: https://
+    url: https://mnIM.ylrzcloud.com
 #是否使用新im
 im:
-    type: NONE
+    type: OPENIM
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
 

+ 53 - 0
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -2540,6 +2540,59 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </select>
 
+    <select id="selectStatisticsList" resultType="com.fs.his.vo.FsUserVO">
+        SELECT
+            u.user_id AS userId,
+            u.nick_name AS nickname,
+            u.avatar,
+            u.phone,
+            u.status,
+            c.company_name AS companyName
+        FROM fs_user u
+        INNER JOIN (
+            -- 多套一层是处理边界,以防有create_time相同的数据
+            SELECT ucu.user_id, MAX(ucu.id) AS latest_ucu_id
+            FROM fs_user_company_user ucu
+            INNER JOIN (
+                SELECT user_id, MAX(create_time) AS max_ct
+                FROM fs_user_company_user
+                WHERE company_id = #{companyId}
+                GROUP BY user_id
+            ) mx ON mx.user_id = ucu.user_id
+                AND ucu.create_time = mx.max_ct
+                AND ucu.company_id = #{companyId}
+            GROUP BY ucu.user_id
+        ) pick ON pick.user_id = u.user_id
+        INNER JOIN fs_user_company_user ucu ON ucu.id = pick.latest_ucu_id
+        LEFT JOIN company c ON c.company_id = ucu.company_id
+        WHERE u.is_del = 0
+        ORDER BY u.user_id DESC
+    </select>
+
+    <select id="selectWatchDaysStatByUserIds" resultType="com.fs.his.vo.FsUserWatchDaysStatVO">
+        SELECT
+            user_id AS userId,
+            COUNT(DISTINCT CASE
+                WHEN last_heartbeat_time &gt;= DATE_SUB(CURDATE(), INTERVAL 2 DAY)
+                THEN DATE(last_heartbeat_time) END) AS watchDaysLast3,
+            COUNT(DISTINCT CASE
+                WHEN last_heartbeat_time &gt;= DATE_SUB(CURDATE(), INTERVAL 4 DAY)
+                THEN DATE(last_heartbeat_time) END) AS watchDaysLast5,
+            COUNT(DISTINCT CASE
+                WHEN last_heartbeat_time &gt;= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
+                THEN DATE(last_heartbeat_time) END) AS watchDaysLast7
+        FROM fs_course_watch_log
+        WHERE company_id = #{companyId}
+          AND last_heartbeat_time IS NOT NULL
+          AND last_heartbeat_time &gt;= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
+          AND last_heartbeat_time &lt; NOW()
+          AND user_id IN
+        <foreach collection="userIds" item="userId" open="(" separator="," close=")">
+            #{userId}
+        </foreach>
+        GROUP BY user_id
+    </select>
+
     <select id="selectAppSalesNewUserCountVO" resultType="com.fs.his.vo.AppSalesCourseStatisticsVO">
         select count(distinct u.user_id) as newAppUserCount, ucu.company_user_id from fs_user u
         left join fs_user_company_user ucu on u.user_id = ucu.user_id and ucu.status=1

+ 148 - 0
fs-service/src/main/resources/mapper/qw/QwMsgAuditMessageMapper.xml

@@ -0,0 +1,148 @@
+<?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.qw.mapper.QwMsgAuditMessageMapper">
+
+    <resultMap type="QwMsgAuditMessage" id="QwMsgAuditMessageResult">
+        <result property="id"    column="id"    />
+        <result property="corpId"    column="corp_id"    />
+        <result property="seq"    column="seq"    />
+        <result property="msgId"    column="msg_id"    />
+        <result property="msgTime"    column="msg_time"    />
+        <result property="msgType"    column="msg_type"    />
+        <result property="textContent"    column="text_content"    />
+        <result property="mediaSdkfileid"    column="media_sdkfileid"    />
+        <result property="mediaMd5sum"    column="media_md5sum"    />
+        <result property="mediaSize"    column="media_size"    />
+        <result property="mediaPlayLength"    column="media_play_length"    />
+        <result property="mediaWidth"    column="media_width"    />
+        <result property="mediaHeight"    column="media_height"    />
+        <result property="mediaFileName"    column="media_file_name"    />
+        <result property="mediaFileExt"    column="media_file_ext"    />
+        <result property="mediaOssUrl"    column="media_oss_url"    />
+        <result property="rawId"    column="raw_id"    />
+        <result property="createTime"    column="create_time"    />
+    </resultMap>
+
+    <sql id="selectQwMsgAuditMessageVo">
+        select id, corp_id, seq, msg_id, msg_time, msg_type, text_content, media_sdkfileid, media_md5sum, media_size, media_play_length, media_width, media_height, media_file_name, media_file_ext, media_oss_url, raw_id, create_time from qw_msg_audit_message
+    </sql>
+
+    <select id="selectQwMsgAuditMessageList" parameterType="QwMsgAuditMessage" resultMap="QwMsgAuditMessageResult">
+        <include refid="selectQwMsgAuditMessageVo"/>
+        <where>
+            <if test="corpId != null  and corpId != ''"> and corp_id = #{corpId}</if>
+            <if test="seq != null "> and seq = #{seq}</if>
+            <if test="msgId != null  and msgId != ''"> and msg_id = #{msgId}</if>
+            <if test="msgTime != null "> and msg_time = #{msgTime}</if>
+            <if test="msgType != null  and msgType != ''"> and msg_type = #{msgType}</if>
+            <if test="textContent != null  and textContent != ''"> and text_content = #{textContent}</if>
+            <if test="mediaSdkfileid != null  and mediaSdkfileid != ''"> and media_sdkfileid = #{mediaSdkfileid}</if>
+            <if test="mediaMd5sum != null  and mediaMd5sum != ''"> and media_md5sum = #{mediaMd5sum}</if>
+            <if test="mediaSize != null "> and media_size = #{mediaSize}</if>
+            <if test="mediaPlayLength != null "> and media_play_length = #{mediaPlayLength}</if>
+            <if test="mediaWidth != null "> and media_width = #{mediaWidth}</if>
+            <if test="mediaHeight != null "> and media_height = #{mediaHeight}</if>
+            <if test="mediaFileName != null  and mediaFileName != ''"> and media_file_name like concat('%', #{mediaFileName}, '%')</if>
+            <if test="mediaFileExt != null  and mediaFileExt != ''"> and media_file_ext = #{mediaFileExt}</if>
+            <if test="rawId != null "> and raw_id = #{rawId}</if>
+        </where>
+    </select>
+
+    <select id="selectQwMsgAuditMessageById" parameterType="Long" resultMap="QwMsgAuditMessageResult">
+        <include refid="selectQwMsgAuditMessageVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertQwMsgAuditMessage" parameterType="QwMsgAuditMessage" useGeneratedKeys="true" keyProperty="id">
+        insert into qw_msg_audit_message
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">corp_id,</if>
+            <if test="seq != null">seq,</if>
+            <if test="msgId != null">msg_id,</if>
+            <if test="msgTime != null">msg_time,</if>
+            <if test="msgType != null">msg_type,</if>
+            <if test="textContent != null">text_content,</if>
+            <if test="mediaSdkfileid != null">media_sdkfileid,</if>
+            <if test="mediaMd5sum != null">media_md5sum,</if>
+            <if test="mediaSize != null">media_size,</if>
+            <if test="mediaPlayLength != null">media_play_length,</if>
+            <if test="mediaWidth != null">media_width,</if>
+            <if test="mediaHeight != null">media_height,</if>
+            <if test="mediaFileName != null">media_file_name,</if>
+            <if test="mediaFileExt != null">media_file_ext,</if>
+            <if test="mediaOssUrl != null">media_oss_url,</if>
+            <if test="rawId != null">raw_id,</if>
+            <if test="createTime != null">create_time,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">#{corpId},</if>
+            <if test="seq != null">#{seq},</if>
+            <if test="msgId != null">#{msgId},</if>
+            <if test="msgTime != null">#{msgTime},</if>
+            <if test="msgType != null">#{msgType},</if>
+            <if test="textContent != null">#{textContent},</if>
+            <if test="mediaSdkfileid != null">#{mediaSdkfileid},</if>
+            <if test="mediaMd5sum != null">#{mediaMd5sum},</if>
+            <if test="mediaSize != null">#{mediaSize},</if>
+            <if test="mediaPlayLength != null">#{mediaPlayLength},</if>
+            <if test="mediaWidth != null">#{mediaWidth},</if>
+            <if test="mediaHeight != null">#{mediaHeight},</if>
+            <if test="mediaFileName != null">#{mediaFileName},</if>
+            <if test="mediaFileExt != null">#{mediaFileExt},</if>
+            <if test="mediaOssUrl != null">#{mediaOssUrl},</if>
+            <if test="rawId != null">#{rawId},</if>
+            <if test="createTime != null">#{createTime},</if>
+         </trim>
+    </insert>
+
+    <update id="updateQwMsgAuditMessage" parameterType="QwMsgAuditMessage">
+        update qw_msg_audit_message
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">corp_id = #{corpId},</if>
+            <if test="seq != null">seq = #{seq},</if>
+            <if test="msgId != null">msg_id = #{msgId},</if>
+            <if test="msgTime != null">msg_time = #{msgTime},</if>
+            <if test="msgType != null">msg_type = #{msgType},</if>
+            <if test="textContent != null">text_content = #{textContent},</if>
+            <if test="mediaSdkfileid != null">media_sdkfileid = #{mediaSdkfileid},</if>
+            <if test="mediaMd5sum != null">media_md5sum = #{mediaMd5sum},</if>
+            <if test="mediaSize != null">media_size = #{mediaSize},</if>
+            <if test="mediaPlayLength != null">media_play_length = #{mediaPlayLength},</if>
+            <if test="mediaWidth != null">media_width = #{mediaWidth},</if>
+            <if test="mediaHeight != null">media_height = #{mediaHeight},</if>
+            <if test="mediaFileName != null">media_file_name = #{mediaFileName},</if>
+            <if test="mediaFileExt != null">media_file_ext = #{mediaFileExt},</if>
+            <if test="mediaOssUrl != null">media_oss_url = #{mediaOssUrl},</if>
+            <if test="rawId != null">raw_id = #{rawId},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteQwMsgAuditMessageById" parameterType="Long">
+        delete from qw_msg_audit_message where id = #{id}
+    </delete>
+
+    <delete id="deleteQwMsgAuditMessageByIds" parameterType="String">
+        delete from qw_msg_audit_message where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+<!--    <insert id="batchInsertMessage" parameterType="com.fs.qw.domain.audit.QwMsgAuditMessage" useGeneratedKeys="true" keyProperty="id">-->
+<!--        insert into qw_msg_audit_message-->
+<!--        (corp_id, seq, msg_id, msg_time, msg_type, text_content,-->
+<!--         media_sdkfileid, media_md5sum, media_size, media_play_length,-->
+<!--         media_width, media_height, media_file_name, media_file_ext,-->
+<!--         raw_id, create_time)-->
+<!--        values-->
+<!--            (#{corpId}, #{seq}, #{msgId}, #{msgTime}, #{msgType}, #{textContent},-->
+<!--             #{mediaSdkfileid}, #{mediaMd5sum}, #{mediaSize}, #{mediaPlayLength},-->
+<!--             #{mediaWidth}, #{mediaHeight}, #{mediaFileName}, #{mediaFileExt},-->
+<!--             #{rawId}, #{createTime})-->
+<!--    </insert>-->
+
+</mapper>

+ 113 - 0
fs-service/src/main/resources/mapper/qw/QwMsgAuditRawMapper.xml

@@ -0,0 +1,113 @@
+<?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.qw.mapper.QwMsgAuditRawMapper">
+
+    <resultMap type="QwMsgAuditRaw" id="QwMsgAuditRawResult">
+        <result property="id"    column="id"    />
+        <result property="corpId"    column="corp_id"    />
+        <result property="seq"    column="seq"    />
+        <result property="msgId"    column="msg_id"    />
+        <result property="action"    column="action"    />
+        <result property="fromUser"    column="from_user"    />
+        <result property="toList"    column="to_list"    />
+        <result property="roomId"    column="room_id"    />
+        <result property="msgTime"    column="msg_time"    />
+        <result property="msgType"    column="msg_type"    />
+        <result property="rawJson"    column="raw_json"    />
+        <result property="createTime"    column="create_time"    />
+    </resultMap>
+
+    <sql id="selectQwMsgAuditRawVo">
+        select id, corp_id, seq, msg_id, action, from_user, to_list, room_id, msg_time, msg_type, raw_json, create_time from qw_msg_audit_raw
+    </sql>
+
+    <select id="selectQwMsgAuditRawList" parameterType="QwMsgAuditRaw" resultMap="QwMsgAuditRawResult">
+        <include refid="selectQwMsgAuditRawVo"/>
+        <where>
+            <if test="corpId != null  and corpId != ''"> and corp_id = #{corpId}</if>
+            <if test="seq != null "> and seq = #{seq}</if>
+            <if test="msgId != null  and msgId != ''"> and msg_id = #{msgId}</if>
+            <if test="action != null  and action != ''"> and action = #{action}</if>
+            <if test="fromUser != null  and fromUser != ''"> and from_user = #{fromUser}</if>
+            <if test="toList != null  and toList != ''"> and to_list = #{toList}</if>
+            <if test="roomId != null  and roomId != ''"> and room_id = #{roomId}</if>
+            <if test="msgTime != null "> and msg_time = #{msgTime}</if>
+            <if test="msgType != null  and msgType != ''"> and msg_type = #{msgType}</if>
+            <if test="rawJson != null  and rawJson != ''"> and raw_json = #{rawJson}</if>
+        </where>
+    </select>
+
+    <select id="selectQwMsgAuditRawById" parameterType="Long" resultMap="QwMsgAuditRawResult">
+        <include refid="selectQwMsgAuditRawVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertQwMsgAuditRaw" parameterType="QwMsgAuditRaw" useGeneratedKeys="true" keyProperty="id">
+        insert into qw_msg_audit_raw
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">corp_id,</if>
+            <if test="seq != null">seq,</if>
+            <if test="msgId != null">msg_id,</if>
+            <if test="action != null">action,</if>
+            <if test="fromUser != null">from_user,</if>
+            <if test="toList != null">to_list,</if>
+            <if test="roomId != null">room_id,</if>
+            <if test="msgTime != null">msg_time,</if>
+            <if test="msgType != null">msg_type,</if>
+            <if test="rawJson != null and rawJson != ''">raw_json,</if>
+            <if test="createTime != null">create_time,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">#{corpId},</if>
+            <if test="seq != null">#{seq},</if>
+            <if test="msgId != null">#{msgId},</if>
+            <if test="action != null">#{action},</if>
+            <if test="fromUser != null">#{fromUser},</if>
+            <if test="toList != null">#{toList},</if>
+            <if test="roomId != null">#{roomId},</if>
+            <if test="msgTime != null">#{msgTime},</if>
+            <if test="msgType != null">#{msgType},</if>
+            <if test="rawJson != null and rawJson != ''">#{rawJson},</if>
+            <if test="createTime != null">#{createTime},</if>
+         </trim>
+    </insert>
+
+    <update id="updateQwMsgAuditRaw" parameterType="QwMsgAuditRaw">
+        update qw_msg_audit_raw
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">corp_id = #{corpId},</if>
+            <if test="seq != null">seq = #{seq},</if>
+            <if test="msgId != null">msg_id = #{msgId},</if>
+            <if test="action != null">action = #{action},</if>
+            <if test="fromUser != null">from_user = #{fromUser},</if>
+            <if test="toList != null">to_list = #{toList},</if>
+            <if test="roomId != null">room_id = #{roomId},</if>
+            <if test="msgTime != null">msg_time = #{msgTime},</if>
+            <if test="msgType != null">msg_type = #{msgType},</if>
+            <if test="rawJson != null and rawJson != ''">raw_json = #{rawJson},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteQwMsgAuditRawById" parameterType="Long">
+        delete from qw_msg_audit_raw where id = #{id}
+    </delete>
+
+    <delete id="deleteQwMsgAuditRawByIds" parameterType="String">
+        delete from qw_msg_audit_raw where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+<!--    <insert id="batchInsertRaw" parameterType="com.fs.qw.domain.audit.QwMsgAuditRaw" useGeneratedKeys="true" keyProperty="id">-->
+<!--        insert into qw_msg_audit_raw-->
+<!--        (corp_id, seq, msg_id, action, from_user, to_list, room_id, msg_time, msg_type, raw_json, create_time)-->
+<!--        values-->
+<!--            (#{corpId}, #{seq}, #{msgId}, #{action}, #{fromUser}, #{toList}, #{roomId}, #{msgTime}, #{msgType}, #{rawJson}, #{createTime})-->
+<!--    </insert>-->
+
+</mapper>

+ 78 - 0
fs-service/src/main/resources/mapper/qw/QwMsgAuditSeqMapper.xml

@@ -0,0 +1,78 @@
+<?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.qw.mapper.QwMsgAuditSeqMapper">
+
+    <resultMap type="QwMsgAuditSeq" id="QwMsgAuditSeqResult">
+        <result property="id"    column="id"    />
+        <result property="corpId"    column="corp_id"    />
+        <result property="seq"    column="seq"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+    </resultMap>
+
+    <sql id="selectQwMsgAuditSeqVo">
+        select id, corp_id, seq, create_time, update_time from qw_msg_audit_seq
+    </sql>
+
+    <select id="selectQwMsgAuditSeqList" parameterType="QwMsgAuditSeq" resultMap="QwMsgAuditSeqResult">
+        <include refid="selectQwMsgAuditSeqVo"/>
+        <where>
+            <if test="corpId != null  and corpId != ''"> and corp_id = #{corpId}</if>
+            <if test="seq != null "> and seq = #{seq}</if>
+        </where>
+    </select>
+
+    <select id="selectQwMsgAuditSeqById" parameterType="Long" resultMap="QwMsgAuditSeqResult">
+        <include refid="selectQwMsgAuditSeqVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertQwMsgAuditSeq" parameterType="QwMsgAuditSeq" useGeneratedKeys="true" keyProperty="id">
+        insert into qw_msg_audit_seq
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">corp_id,</if>
+            <if test="seq != null">seq,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateTime != null">update_time,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">#{corpId},</if>
+            <if test="seq != null">#{seq},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+         </trim>
+    </insert>
+
+    <update id="updateQwMsgAuditSeq" parameterType="QwMsgAuditSeq">
+        update qw_msg_audit_seq
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="corpId != null and corpId != ''">corp_id = #{corpId},</if>
+            <if test="seq != null">seq = #{seq},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteQwMsgAuditSeqById" parameterType="Long">
+        delete from qw_msg_audit_seq where id = #{id}
+    </delete>
+
+    <delete id="deleteQwMsgAuditSeqByIds" parameterType="String">
+        delete from qw_msg_audit_seq where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <update id="advanceSeq">
+        update qw_msg_audit_seq
+        set seq = #{newSeq},
+            update_time = now()
+        where corp_id = #{corpId}
+          and (seq is null or seq &lt;= #{newSeq})
+    </update>
+
+</mapper>